Skip to content
Merged
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
20 changes: 13 additions & 7 deletions feeds/skills/.system/files/skill-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: skill-authoring
description: "How to create, edit, and manage Netclaw skills. Read this when you need to synthesize a new skill from a session, understand the skill file format, or use the skill_manage tool."
metadata:
author: netclaw
version: "1.7.1"
version: "1.7.2"
---

# Skill Authoring
Expand Down Expand Up @@ -49,6 +49,7 @@ skill-name/
references/ # Optional: detail documents loaded on demand
scripts/ # Optional: executable helpers
assets/ # Optional: templates, static resources
tools/ # Optional: any additional files/directories are allowed
```

### Flat-file layout
Expand All @@ -59,8 +60,8 @@ skill-name.md # YAML frontmatter + markdown instructions (no resources)

Flat `.md` files with valid YAML frontmatter are accepted as skills for
compatibility with Claude Code and other platforms. Flat-file skills cannot
have resource subdirectories. If both `skill-name/SKILL.md` and
`skill-name.md` exist, the directory version takes precedence.
have resources. If both `skill-name/SKILL.md` and `skill-name.md` exist, the
directory version takes precedence.

Name matching depends on the source:
- Netclaw-managed local skills use strict identity checks. The frontmatter
Expand Down Expand Up @@ -169,13 +170,16 @@ Keep under 5000 tokens. Include:

## Progressive Disclosure

Put detail in subdirectories, not in the main SKILL.md:
Put detail in additional files, not in the main SKILL.md. Netclaw discovers any
safe relative file under the skill directory as a resource; `references/`,
`scripts/`, and `assets/` are conventions, not hard requirements:

| Directory | Purpose |
|-----------|---------|
| `references/` | Detailed documentation, research, examples |
| `scripts/` | Executable helpers (shell scripts, Python) |
| `assets/` | Templates, static files, config samples |
| `tools/` or other paths | Additional helper files allowed by AgentSkills.io |

The skill body references these files explicitly: "See
`references/deployment-checklist.md` for the full checklist." The agent loads
Expand Down Expand Up @@ -211,7 +215,7 @@ Content scanning rules:
- All skills are scanned uniformly regardless of origin — system, user, and all other skills use the same policy (High risk → Reject, Medium → Warn, Low → Allow).
- Rejected scans fail closed: Netclaw returns the rejection reason and leaves the previous on-disk content unchanged.
- Warnings may still allow a mutation or read, but the warning text is surfaced in the tool result.
- Resource files must stay under `references/`, `scripts/`, or `assets/`; other paths are rejected.
- Resource paths must be safe relative file paths under the skill directory. Absolute paths, empty segments, `.`, `..`, and root `SKILL.md` are rejected.

Hard rules:
- The frontmatter `name` must match the target skill name for `create` and `edit`.
Expand All @@ -228,10 +232,12 @@ Skills live in two locations:
| Directory | Source | Editable |
|-----------|--------|----------|
| `~/.netclaw/skills/.system/` | Official Netclaw feed (synced from CDN) | No — read-only |
| `~/.netclaw/skills/.server-feeds/<feed>/` | Private skill-server feeds | No — read-only |
| `~/.netclaw/skills/` (root) | Operator-placed or user-created via `skill_manage` | Yes |

System skills (`.system/`) cannot be edited, patched, or deleted via
`skill_manage`. They are maintained by the Netclaw release process.
System skills (`.system/`) and private server-feed skills (`.server-feeds/`)
cannot be edited, patched, or deleted via `skill_manage`. They are maintained
by their sync services.

All skills — regardless of origin — are visible in the skill index and
available to all sessions. The skill index is a compressed file listing
Expand Down
4 changes: 3 additions & 1 deletion src/Netclaw.Actors.Tests/Skills/SkillScannerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ public void Discovers_directory_based_skill_with_resources()
WriteSkillFile("web-search", "references/flight-pricing.md", "# Flight Pricing");
WriteSkillFile("web-search", "references/restaurant-search.md", "# Restaurant Search");
WriteSkillFile("web-search", "scripts/validate.sh", "#!/bin/bash\necho ok");
WriteSkillFile("web-search", "tools/check", "#!/bin/bash\necho check");

var result = SkillScanner.Scan(_skillsDir);

Expand All @@ -145,10 +146,11 @@ public void Discovers_directory_based_skill_with_resources()
Assert.Equal(Path.Combine(_skillsDir, "web-search", "SKILL.md"), result.AcceptedSkills[0].FilePath);
Assert.Equal(Path.Combine(_skillsDir, "web-search"), result.AcceptedSkills[0].SkillDirectory);
Assert.NotNull(result.AcceptedSkills[0].ResourcePaths);
Assert.Equal(3, result.AcceptedSkills[0].ResourcePaths!.Count);
Assert.Equal(4, result.AcceptedSkills[0].ResourcePaths!.Count);
Assert.Contains("references/flight-pricing.md", result.AcceptedSkills[0].ResourcePaths!);
Assert.Contains("references/restaurant-search.md", result.AcceptedSkills[0].ResourcePaths!);
Assert.Contains("scripts/validate.sh", result.AcceptedSkills[0].ResourcePaths!);
Assert.Contains("tools/check", result.AcceptedSkills[0].ResourcePaths!);
}

[Fact]
Expand Down
48 changes: 43 additions & 5 deletions src/Netclaw.Actors.Tests/Tools/SkillToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,25 @@ public async Task SkillReadResource_ReadsValidPath()
Assert.Equal("# Guide Content", result);
}

[Fact]
public async Task SkillReadResource_ReadsArbitraryAdditionalFilePath()
{
WriteSkill("my-skill", """
---
name: my-skill
description: Test skill.
---
# My Skill
""");
WriteFile("my-skill", "tools/check", "#!/bin/bash\necho ok");
ScanSkills();

var tool = new SkillReadResourceTool(_registry, new NoOpSkillContentScanner());
var result = await tool.ExecuteAsync(ToolInput.Create("SkillName", "my-skill", "ResourcePath", "tools/check"), PersonalCtx, TestContext.Current.CancellationToken);

Assert.Contains("echo ok", result);
}

[Fact]
public async Task SkillReadResource_RejectsPathTraversal()
{
Expand All @@ -351,7 +370,7 @@ public async Task SkillReadResource_RejectsPathTraversal()
var tool = new SkillReadResourceTool(_registry, new NoOpSkillContentScanner());
var result = await tool.ExecuteAsync(ToolInput.Create("SkillName", "my-skill", "ResourcePath", "../../etc/passwd"), PersonalCtx, TestContext.Current.CancellationToken);

Assert.Contains("not allowed", result);
Assert.Contains("cannot contain", result);
}

[Fact]
Expand Down Expand Up @@ -387,7 +406,7 @@ public async Task SkillReadResource_RejectsDisallowedPrefix()
var tool = new SkillReadResourceTool(_registry, new NoOpSkillContentScanner());
var result = await tool.ExecuteAsync(ToolInput.Create("SkillName", "my-skill", "ResourcePath", "SKILL.md"), PersonalCtx, TestContext.Current.CancellationToken);

Assert.Contains("must start with", result);
Assert.Contains("Use skill_load", result);
}

[Fact]
Expand Down Expand Up @@ -495,7 +514,25 @@ Original content here.
}

[Fact]
public async Task SkillManage_WriteFile_ValidatesPath()
public async Task SkillManage_WriteFile_RejectsTraversalPath()
{
WriteSkill("wf-test", """
---
name: wf-test
description: Write file test.
---
# WF
""");
ScanSkills();

var tool = CreateManageTool();
var result = await tool.ExecuteAsync(ToolInput.Create("Action", "write_file", "Name", "wf-test", "FilePath", "../file.md", "FileContent", "content"), PersonalCtx, TestContext.Current.CancellationToken);

Assert.Contains("cannot contain", result);
}

[Fact]
public async Task SkillManage_WriteFile_AllowsArbitraryAdditionalFilePath()
{
WriteSkill("wf-test", """
---
Expand All @@ -507,9 +544,10 @@ public async Task SkillManage_WriteFile_ValidatesPath()
ScanSkills();

var tool = CreateManageTool();
var result = await tool.ExecuteAsync(ToolInput.Create("Action", "write_file", "Name", "wf-test", "FilePath", "baddir/file.md", "FileContent", "content"), PersonalCtx, TestContext.Current.CancellationToken);
var result = await tool.ExecuteAsync(ToolInput.Create("Action", "write_file", "Name", "wf-test", "FilePath", "tools/check", "FileContent", "#!/bin/bash\necho ok"), PersonalCtx, TestContext.Current.CancellationToken);

Assert.Contains("must start with", result);
Assert.Contains("File written: tools/check", result);
Assert.True(File.Exists(Path.Combine(_paths.SkillsDirectory, "wf-test", "tools", "check")));
}

[Fact]
Expand Down
60 changes: 24 additions & 36 deletions src/Netclaw.Actors/Skills/SkillScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ namespace Netclaw.Actors.Skills;
/// <summary>
/// Discovers skills under the skills directory using the AgentSkills.io
/// directory layout: each skill is a directory containing <c>SKILL.md</c>
/// with YAML frontmatter. Optional subdirectories (<c>scripts/</c>,
/// <c>references/</c>, <c>assets/</c>) hold progressive-disclosure resources.
/// with YAML frontmatter. Additional files under the skill directory are
/// progressive-disclosure resources.
/// </summary>
public static partial class SkillScanner
{
Expand All @@ -28,11 +28,6 @@ public static partial class SkillScanner
/// </summary>
public const string SystemCategory = ".system";

/// <summary>
/// Standard subdirectories within a skill directory that contain resources.
/// </summary>
private static readonly string[] ResourceSubdirectories = ["scripts", "references", "assets"];

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than scan static skill resource directories, we enumerate them on the fly now since the AgentSkills.io standard doesn't mandate strictly these ones.


[GeneratedRegex(@"^#\s+(.+)$", RegexOptions.Multiline)]
private static partial Regex HeadingRegex();

Expand Down Expand Up @@ -571,47 +566,40 @@ private static (bool HasSubagentMetadata, string? Subagent, string? Error) Parse
}

/// <summary>
/// Enumerates resource files in standard subdirectories of a skill directory.
/// Enumerates non-root resource files under a skill directory.
/// Returns null if no resources are found.
/// </summary>
private static IReadOnlyList<string>? EnumerateResources(string skillDirectory, string rootDirectory, List<SkillScanIssue> issues, bool allowSymlinks = false)
{
List<string>? resources = null;
var canonicalRoot = PathUtility.Normalize(rootDirectory);

foreach (var subDirName in ResourceSubdirectories)
string[] files;
try
{
var subDir = Path.Combine(skillDirectory, subDirName);
if (!Directory.Exists(subDir))
continue;
files = Directory.GetFiles(skillDirectory, "*", SearchOption.AllDirectories);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

find all files in this skill directory

}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
issues.Add(new SkillScanIssue(
Path: skillDirectory,
Kind: SkillScanIssueKind.ResourceEnumerationFailed,
Message: $"Failed to enumerate resources: {ex.Message}"));
return null;
}

if (ValidateCanonicalPath(subDir, canonicalRoot, issues, $"resource directory '{subDirName}'", allowSymlinks) is null)
return null;
foreach (var file in files.OrderBy(static f => f, StringComparer.Ordinal))
{
if (PathUtility.AreEquivalentPaths(file, Path.Combine(skillDirectory, SkillFileName)))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate symlinks et al

continue;

string[] files;
try
{
files = Directory.GetFiles(subDir, "*", SearchOption.AllDirectories);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
issues.Add(new SkillScanIssue(
Path: subDir,
Kind: SkillScanIssueKind.ResourceEnumerationFailed,
Message: $"Failed to enumerate resources: {ex.Message}"));
if (ValidateCanonicalPath(file, canonicalRoot, issues, "resource file", allowSymlinks) is null)
return null;
}

foreach (var file in files)
{
if (ValidateCanonicalPath(file, canonicalRoot, issues, "resource file", allowSymlinks) is null)
return null;

var relativePath = Path.GetRelativePath(skillDirectory, file)
.Replace(Path.DirectorySeparatorChar, '/');
resources ??= [];
resources.Add(relativePath);
}
var relativePath = Path.GetRelativePath(skillDirectory, file)
.Replace(Path.DirectorySeparatorChar, '/');
resources ??= [];
resources.Add(relativePath);
}

return resources;
Expand Down
50 changes: 14 additions & 36 deletions src/Netclaw.Actors/Tools/SkillManageTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ namespace Netclaw.Actors.Tools;
Grant = "builtin")]
public sealed partial class SkillManageTool : NetclawTool<SkillManageTool.Params>
{
private static readonly HashSet<string> AllowedResourcePrefixes =
new(StringComparer.OrdinalIgnoreCase) { "references", "scripts", "assets" };

[GeneratedRegex(@"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")]
private static partial Regex ValidNameRegex();

Expand Down Expand Up @@ -212,9 +209,9 @@ private async Task<string> PatchAsync(Params args, CancellationToken ct)
var targetPath = skill.FilePath;
if (!string.IsNullOrWhiteSpace(args.FilePath))
{
var fileError = ValidateResourcePath(args.FilePath);
if (fileError is not null) return fileError;
targetPath = Path.Combine(skill.SkillDirectory, args.FilePath);
if (!SkillResourcePath.TryNormalize(args.FilePath, out var normalizedPath, out var fileError))
return SkillResourcePath.FormatManageError(fileError);
targetPath = Path.Combine(skill.SkillDirectory, normalizedPath);
}

if (!File.Exists(targetPath))
Expand Down Expand Up @@ -244,7 +241,7 @@ private async Task<string> PatchAsync(Params args, CancellationToken ct)

var scanSubject = targetPath == skill.FilePath
? name
: $"{name}:{args.FilePath}";
: $"{name}:{Path.GetRelativePath(skill.SkillDirectory, targetPath).Replace(Path.DirectorySeparatorChar, '/')}";
var scanResult = await _scanner.ScanAsync(scanSubject, newContent, ct);
if (!scanResult.IsAllowed)
return $"Content scan rejected: {scanResult.Reason}";
Expand Down Expand Up @@ -319,15 +316,15 @@ private async Task<string> WriteFileAsync(Params args, CancellationToken ct)
var readOnlyError = GuardReadOnly(skill, "write files in");
if (readOnlyError is not null) return readOnlyError;

var fileError = ValidateResourcePath(args.FilePath);
if (fileError is not null) return fileError;
if (!SkillResourcePath.TryNormalize(args.FilePath, out var normalizedPath, out var fileError))
return SkillResourcePath.FormatManageError(fileError);

var fullPath = Path.GetFullPath(Path.Combine(skill.SkillDirectory, args.FilePath));
var fullPath = Path.GetFullPath(Path.Combine(skill.SkillDirectory, normalizedPath));
if (!PathUtility.IsWithinRoot(fullPath, skill.SkillDirectory))
return "Resolved path is outside the skill directory.";

var scanResult = await _scanner.ScanAsync(
$"{name}:{args.FilePath}",
$"{name}:{normalizedPath}",
args.FileContent,
ct);
if (!scanResult.IsAllowed)
Expand All @@ -338,7 +335,7 @@ private async Task<string> WriteFileAsync(Params args, CancellationToken ct)
AtomicWrite(fullPath, args.FileContent);
var rescan = RescanAndUpdateIndex();

var message = $"File written: {args.FilePath}";
var message = $"File written: {normalizedPath}";
if (scanResult.Verdict == ScanVerdict.Warning)
message += $" (warning: {scanResult.Reason})";

Expand All @@ -361,15 +358,15 @@ private string RemoveFile(Params args)
var readOnlyError = GuardReadOnly(skill, "remove files from");
if (readOnlyError is not null) return readOnlyError;

var fileError = ValidateResourcePath(args.FilePath);
if (fileError is not null) return fileError;
if (!SkillResourcePath.TryNormalize(args.FilePath, out var normalizedPath, out var fileError))
return SkillResourcePath.FormatManageError(fileError);

var fullPath = Path.GetFullPath(Path.Combine(skill.SkillDirectory, args.FilePath));
var fullPath = Path.GetFullPath(Path.Combine(skill.SkillDirectory, normalizedPath));
if (!PathUtility.IsWithinRoot(fullPath, skill.SkillDirectory))
return "Resolved path is outside the skill directory.";

if (!File.Exists(fullPath))
return $"File not found: {args.FilePath}";
return $"File not found: {normalizedPath}";

File.Delete(fullPath);

Expand All @@ -381,7 +378,7 @@ private string RemoveFile(Params args)
Directory.Delete(dir);
}

return AppendScanWarnings($"File removed: {args.FilePath}", RescanAndUpdateIndex());
return AppendScanWarnings($"File removed: {normalizedPath}", RescanAndUpdateIndex());
}

// --- Helpers ---
Expand Down Expand Up @@ -441,25 +438,6 @@ private bool IsSystemSkill(string name)
return null;
}

private static string? ValidateResourcePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return "FilePath is required.";

if (Path.IsPathRooted(path))
return "Absolute paths are not allowed.";

if (path.Contains("..", StringComparison.Ordinal))
return "Path traversal ('..') is not allowed.";

var normalized = path.Replace('\\', '/');
var firstSegment = normalized.Split('/')[0];
if (!AllowedResourcePrefixes.Contains(firstSegment))
return $"FilePath must start with one of: {string.Join(", ", AllowedResourcePrefixes)}. Got '{firstSegment}'.";

return null;
}

private string? GuardReadOnly(SkillEntry skill, string verb)
{
if (IsSystemCategory(skill))
Expand Down
Loading
Loading