Skip to content

Skills & plugins: skill tool, skills loader, plugin manifests#123

Merged
gnanam1990 merged 4 commits into
mainfrom
skills-plugins
Jun 7, 2026
Merged

Skills & plugins: skill tool, skills loader, plugin manifests#123
gnanam1990 merged 4 commits into
mainfrom
skills-plugins

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Module — skills & plugins

Next reviewable slice of the runtime-core split (off main, now that #119 tools merged). Clean extractinternal/skills is new, internal/plugins had zero drift on main. No new deps.

What's in it

  • internal/skills — loads */SKILL.md frontmatter from the skills dir (resolves its own XDG ~/.local/share/zero/skills); no YAML dependency.
  • skill tool (internal/tools/skill.go), read-only, registered in CoreReadOnlyTools (so it's in the agent core and the MCP read-only default via serve.go). Path-safe by construction: it Load()s all skills and matches by exact name — it never builds a filesystem path from the model-supplied arg, so there's no traversal surface. The dir is read via os.ReadDir, not user input.
  • internal/plugins — plugin manifest metadata enrichment (Author/License/Keywords/Interface). Forward code — the cli plugin-loading wiring lands in a later PR.

Cross-package

specialist.knownToolNames gains "skill" (the TestKnownToolNamesMatchCoreRegistry invariant — skill is now a core tool); the CoreReadOnlyTools count test goes 4→5.

Testing

go build ./..., go vet ./..., go test ./..., go test -race ./internal/skills/ ./internal/tools/, and GOOS=windows go build ./... all green.

MCP hardening (nested-schema passthrough + hung-server timeout fix) is a separate follow-up PR to keep this reviewable. The TUI shell + cli wiring remain blocked on the agent module.

Part of decomposing #101 (draft); subagents excluded.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Local "skills" system for authoring, discovering, and retrieving reusable instruction skills.
    • Built-in read-only skill tool to fetch skill content by name.
    • Plugin manifests can include optional author, license, keywords, homepage, and UI/interface metadata.
  • Improvements

    • Manifest parsing is tolerant of missing/malformed optional metadata; listings surface available metadata (author, license, keywords).
    • Skills loader normalizes content, deduplicates deterministically, sorts results, respects env/default locations, and skips unsafe symlinks.
  • Tests

    • Added tests for manifest parsing, metadata listing, skills loading, duplicates, lookup, defaults, and tool behavior.

Module of the runtime-core split (off main, after #119 tools merged). Clean extract — internal/skills is new; internal/plugins had no drift on main. No new deps.

- internal/skills: loads */SKILL.md frontmatter from the skills dir (resolves its own XDG dir); no YAML dependency.

- skill tool (internal/tools/skill.go), read-only, registered in CoreReadOnlyTools (so it's in the agent core + the MCP read-only default). Path-safe: it Load()s all skills and matches by exact name — it never builds a path from the model-supplied arg, so there is no traversal surface. knownToolNames gains "skill" for the specialist cross-package invariant; CoreReadOnlyTools count test bumped 4->5.

- internal/plugins: plugin manifest metadata enrichment (Author/License/Keywords/Interface). FORWARD code — the cli plugin-loading wiring lands later.

MCP hardening (nested-schema + hung-server fix) is a SEPARATE follow-up PR. build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 12ed50dce728
Changed files (10): internal/plugins/plugins.go, internal/plugins/plugins_test.go, internal/skills/skills.go, internal/skills/skills_test.go, internal/skills/skills_unix_test.go, internal/specialist/manifest.go, internal/tools/registry.go, internal/tools/registry_test.go, internal/tools/skill.go, internal/tools/skill_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cc53a900-dc42-4215-8dbc-a0b35ecf4d42

📥 Commits

Reviewing files that changed from the base of the PR and between efffc7c and 12ed50d.

📒 Files selected for processing (3)
  • internal/plugins/plugins.go
  • internal/skills/skills.go
  • internal/skills/skills_unix_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/skills/skills.go

Walkthrough

Adds optional plugin manifest metadata and formatting; a SKILL.md-based skills loader with confinement, parsing, deduplication, List/Get, and tests; a read-only skill tool that returns skill Markdown; and integrates "skill" into specialist validation and the core tools registry.

Changes

Skills System Integration

Layer / File(s) Summary
Plugin metadata model and parsing
internal/plugins/plugins.go, internal/plugins/plugins_test.go
LoadedPlugin gains optional Author, License, Keywords, Homepage, and Interface fields; ParseManifest populates them using tolerant helpers; FormatList renders metadata lines; tests verify parsing with and without optional metadata and listing output.
Skills discovery and parsing
internal/skills/skills.go
New Skill and DuplicateName types; DefaultDir(env) resolves skills directory; Load(dir) scans immediate subdirectories for confined SKILL.md, parses optional --- frontmatter (name/description), normalizes content, rejects escaping symlinks/non-regular targets, deduplicates by parsed name (lexicographic winner), records collisions, sorts results, and exposes List/Get.
Skills package tests
internal/skills/skills_test.go
Tests cover frontmatter parsing/stripping, name derivation, skipping malformed files, symlink confinement, deterministic sorting and duplicate winner selection, Duplicates reporting, Get/List behaviors, and DefaultDir env resolution.
Unix non-regular file regression
internal/skills/skills_unix_test.go
Regression test ensuring Load doesn't hang on a FIFO SKILL.md and returns only regular skills (skipped if FIFO unsupported).
Skill tool implementation
internal/tools/skill.go
NewSkillTool(dir) constructs a read-only tool defaulting to skills.DefaultDir; Run enforces exactly-one of name/skill, loads skills, returns matched Content, or errors listing available skill names or no-skills condition.
Skill tool tests
internal/tools/skill_test.go
Tests verify tool metadata (read-only), content retrieval for known skills, alias support (skill), unknown-skill listing of available names, missing-name error, and no-skills-available handling.
Specialist and tools registry integration
internal/specialist/manifest.go, internal/tools/registry.go, internal/tools/registry_test.go
Adds "skill" to known tool names; registers NewSkillTool("") in CoreReadOnlyTools; updates registry test to expect 5 core tools.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant SkillTool
  participant SkillsLoader
  Caller->>SkillTool: Run(name or skill)
  SkillTool->>SkillsLoader: Load(dir)
  SkillsLoader-->>SkillTool: skills or error
  alt exact skill match
    SkillTool-->>Caller: StatusOK + Content
  else no match / no skills / load error
    SkillTool-->>Caller: StatusError + message (available names or reason)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Gitlawb/zero#47: Both PRs touch the core read-only tools registry and the CoreReadOnlyTools set.
  • Gitlawb/zero#61: Related plugin subsystem changes — this PR extends plugin manifest fields and listing behavior building on that subsystem.
  • Gitlawb/zero#117: Related specialist manifest validation edits; both PRs modify known tool validation behavior.

Suggested reviewers

  • Vasanthdev2004
  • anandh8x
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly summarizes the three main additions: the skills loader package, the skill tool integration, and plugin manifest enrichment.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch skills-plugins

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/plugins/plugins.go`:
- Around line 116-121: The JSON omitempty on non-pointer struct fields is
ineffective for Author and Interface, so change the Plugin struct fields Author
and Interface to pointer types (e.g., *PluginAuthor and *PluginInterface) so
zero values serialize as null/omitted; update all places that construct or copy
Plugin values (constructors, unmarshalling, tests, or any code referencing
Plugin.Author or Plugin.Interface) to use pointers or nil appropriately and
adjust any dereferences (e.g., checks like if p.Author != nil) to preserve
behavior.

In `@internal/skills/skills_test.go`:
- Around line 215-230: Update the TestListReturnsNamesAndDescriptions test to
also assert that List strips the skill body by checking that listed[0].Content
(and listed[1].Content) is an empty string; locate the test function
TestListReturnsNamesAndDescriptions and the call to List(dir) and add assertions
after the existing Name/Description checks to ensure Content == "" for each
returned item to prevent leaking full skill bodies from List.

In `@internal/skills/skills.go`:
- Around line 47-50: DefaultDir currently falls back to a relative
".local/share/zero/skills" when $XDG_DATA_HOME and $HOME are empty; change the
fallback to an absolute location to avoid binding to CWD. In the DefaultDir
function in internal/skills/skills.go, when base is empty (after checking
XDG_DATA_HOME and os.UserHomeDir()), set base to an absolute fallback such as
filepath.Join(os.TempDir(), "zero", "skills") or call filepath.Abs on the
computed base so it becomes absolute; use os.TempDir() and/or filepath.Abs to
guarantee an absolute path before returning filepath.Join(base, "zero",
"skills").

In `@internal/tools/skill.go`:
- Around line 33-43: The parameters Schema for the skill loader currently only
defines "name" and sets AdditionalProperties: false, but runtime code accepts an
alias "skill" (causing schema validation to reject calls before Run); either add
"skill" to the Schema Properties (and adjust Required/validation) or remove
alias handling so only "name" is accepted—update the Schema block (Parameters:
Schema, Properties map entries for "name"/"skill", Required slice and
AdditionalProperties) to match the runtime alias behavior used by the skill
loading logic (e.g., the Run handler that reads "skill"/"name").
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 78178b75-39af-4d33-8476-7897b60bd718

📥 Commits

Reviewing files that changed from the base of the PR and between f3463d6 and c8f4105.

📒 Files selected for processing (9)
  • internal/plugins/plugins.go
  • internal/plugins/plugins_test.go
  • internal/skills/skills.go
  • internal/skills/skills_test.go
  • internal/specialist/manifest.go
  • internal/tools/registry.go
  • internal/tools/registry_test.go
  • internal/tools/skill.go
  • internal/tools/skill_test.go

Comment thread internal/plugins/plugins.go Outdated
Comment thread internal/skills/skills_test.go
Comment thread internal/skills/skills.go
Comment thread internal/tools/skill.go
…s, dir fallback, List test

- plugins: Author/Interface are now *PluginAuthor/*PluginInterface so omitempty actually omits them — a non-pointer struct is never empty to encoding/json, so the old form emitted author:{}/interface:{} and changed the serialized shape of plugins that don't set them. parseAuthor/parseInterface return nil when all fields are empty; formatAuthor takes the pointer. (Major)

- skill: schema now declares the 'skill' alias alongside 'name' (and drops strict Required) so the alias survives schema validators that reject unknown keys (AdditionalProperties:false); Run still enforces exactly-one-of via aliasedStringArg. (Major)

- skills.DefaultDir: return "" instead of a relative ".local/share/..." path when neither XDG_DATA_HOME nor a resolvable HOME exists, so skills can't bind to the process CWD (load("") already no-ops). (Minor)

- skills_test: TestListReturnsNamesAndDescriptions now asserts List strips Content so a listing can't silently leak full skill bodies. (Minor)

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review — addressed all 4: (1) omitempty — Author/Interface are now pointers so empty metadata is actually omitted (parse funcs return nil when empty); (2) skill schema — declares the skill alias + drops strict Required so the alias survives AdditionalProperties:false validators (Run enforces one-of); (3) DefaultDir — returns "" instead of a relative path when home is unresolvable (no CWD binding); (4) List test — now asserts Content is stripped. build/vet/-race/full-suite + windows green.

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

@gnanam1990 — thanks for the thorough write-up. All four fixes look sound:

# Fix Assessment
1 Author/Interface as pointers + nil-returning parse funcs Correct — zero structs won't round-trip through omitempty without pointer indirection
2 skill alias declared in schema + Required dropped; Run enforces one-of Right call — strict validators reject unknown keys in required that aren't in properties
3 DefaultDir returns "" on unresolvable home Eliminates the CWD-binding hazard
4 List test asserts Content == "" Closes the coverage gap

Risk surface note (per policy on PRs touching skills/plugins/MCP): The skill tool is registered into CoreReadOnlyTools and the MCP read-only set. The tool reads from a fixed, user-controlled directory (ZERO_SKILLS_DIR or XDG path), does not construct filesystem paths from model input, and uses os.ReadDir for traversal — not a blocker, but reviewers should confirm the MCP serve path enforces the read-only label downstream before the tool is callable by a remote client.

Plugin-loading CLI wiring is deferred, so the plugin manifest code introduces no new runtime attack surface in this PR.

Triggering a full review now.


🧠 Learnings used
Learnt from: CR
Repo: Gitlawb/openclaude PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-06-06T00:19:44.234Z
Learning: Verify that the review calls out the risk surface and whether it introduces a blocker if the PR touches auth, provider routing, permissions, outbound network behavior, background execution, startup/config-home behavior, skills/plugins/MCP, CI permissions, or release scripts.
✅ Action performed

Full review finished.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: request changes.

The earlier metadata/alias/default-dir/listing issues look fixed on this head. One safety blocker remains before exposing skill as a permission-allow core/MCP tool: the loader should reject or confine symlinked SKILL.md files so a skills directory cannot be used to read arbitrary files outside the skills root.

Comment thread internal/skills/skills.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/tools/skill.go (1)

68-70: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Minor UX: Empty dir in error message could be clearer.

When skills.DefaultDir returns "" (e.g., HOME and XDG_DATA_HOME unavailable), the error message on Line 69 would render as "no skills are available (looked in )." with an empty string interpolated. Consider handling this edge case for clarity.

Suggested improvement
 if len(loaded) == 0 {
+  if tool.dir == "" {
+    return errorResult("Error: no skills are available (skills directory not configured).")
+  }
   return errorResult(fmt.Sprintf("Error: no skills are available (looked in %s).", tool.dir))
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tools/skill.go` around lines 68 - 70, The error message when no
skills are loaded interpolates tool.dir and can produce "looked in " if tool.dir
is empty; update the return in the len(loaded) == 0 branch to detect an empty
tool.dir (or use skills.DefaultDir if applicable) and substitute a clear
placeholder like "no skills directory configured" (e.g., compute dirDisplay :=
tool.dir; if dirDisplay=="" { dirDisplay = "no skills directory configured" }
and use dirDisplay in the fmt message) so the message reads clearly when
skills.DefaultDir returns "".
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/tools/skill.go`:
- Around line 68-70: The error message when no skills are loaded interpolates
tool.dir and can produce "looked in " if tool.dir is empty; update the return in
the len(loaded) == 0 branch to detect an empty tool.dir (or use
skills.DefaultDir if applicable) and substitute a clear placeholder like "no
skills directory configured" (e.g., compute dirDisplay := tool.dir; if
dirDisplay=="" { dirDisplay = "no skills directory configured" } and use
dirDisplay in the fmt message) so the message reads clearly when
skills.DefaultDir returns "".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4ea6b28a-7ecf-4013-80db-1e74285c938f

📥 Commits

Reviewing files that changed from the base of the PR and between c8f4105 and 1224a1d.

📒 Files selected for processing (5)
  • internal/plugins/plugins.go
  • internal/plugins/plugins_test.go
  • internal/skills/skills.go
  • internal/skills/skills_test.go
  • internal/tools/skill.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • internal/plugins/plugins_test.go
  • internal/plugins/plugins.go
  • internal/skills/skills.go
  • internal/skills/skills_test.go

…skills root

skill is a permission-allow read-only core/MCP tool, so the loader must not follow a symlinked SKILL.md (or skill dir) out of the skills root and become an arbitrary-file reader. load() now resolves the skills root via EvalSymlinks and, for each entry, resolves SKILL.md through symlinks and verifies the real path stays under the root (confineSkillPath); an escaping or unreadable path is skipped rather than read. Mirrors the grep/read_file confinement. Legit skills under a symlinked root (e.g. macOS /tmp) still load because the root is resolved too.

Regression: TestLoadSkipsSymlinkedSkillFileEscapingRoot (a SKILL.md symlinked to a secret outside the root is skipped, not read).

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@Vasanthdev2004 good catch — fixed. load() now confines each SKILL.md to the skills root: it resolves the root via EvalSymlinks and, per entry, resolves SKILL.md through symlinks and verifies the real path stays under the root (confineSkillPath), skipping anything that escapes rather than reading it. So a symlinked SKILL.md (or skill dir) can no longer make the permission-allow skill tool read arbitrary local files. Mirrors the grep/read_file confinement; legit skills under a symlinked root (macOS /tmp) still load since the root is resolved too. Regression test added (TestLoadSkipsSymlinkedSkillFileEscapingRoot). build/vet/-race/full-suite + windows green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
internal/skills/skills.go (1)

116-127: ⚠️ Potential issue | 🟠 Major

Prevent symlink-based local file disclosure via skill tool (SKILL.md read)

internal/skills/skills.go builds manifestPath := filepath.Join(dir, entry.Name(), skillFileName) and then calls os.ReadFile(manifestPath) (line ~122) without rejecting symlinked skill directories or symlinked SKILL.md. Since internal/tools/skill.go returns the loaded skill.Content directly as the skill tool output, a local user-controlled skills root (ZERO_SKILLS_DIR / XDG) containing a malicious symlinked SKILL.md can let the model retrieve arbitrary files outside the skills root (the tool can also enumerate available skills via the “unknown skill” error). scrubResultSecrets only redacts known secret patterns; it does not prevent arbitrary content leakage.

Add symlink containment checks before reading (skip symlink directories and ensure SKILL.md is not a symlink, or resolve and verify it stays under the resolved skills root). Also add a regression test for a symlinked SKILL.md being skipped.

Reject symlinks before reading `SKILL.md`
 	for _, entry := range entries {
 		if !entry.IsDir() {
 			continue
 		}
+		// Reject symlinked skill directories to prevent reading arbitrary files.
+		if entry.Type()&os.ModeSymlink != 0 {
+			continue
+		}
 		manifestPath := filepath.Join(dir, entry.Name(), skillFileName)
+		// Verify SKILL.md itself is not a symlink.
+		info, err := os.Lstat(manifestPath)
+		if err != nil {
+			continue
+		}
+		if info.Mode()&os.ModeSymlink != 0 {
+			continue
+		}
 		data, err := os.ReadFile(manifestPath)
 		if err != nil {
 			// Missing or unreadable SKILL.md (including the case where it is a
 			// directory) is skipped, not fatal — one bad skill must not hide the rest.
 			continue
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/skills/skills.go` around lines 116 - 127, The code builds
manifestPath := filepath.Join(dir, entry.Name(), skillFileName) and reads it
without symlink checks; change the logic in the loop that iterates entries in
internal/skills/skills.go (the block using entries, entry, dir, manifestPath,
skillFileName) to first skip any entry that is a symlink (use os.Lstat and check
ModeSymlink) and then, before calling os.ReadFile, check whether manifestPath
itself is a symlink (skip if so) or resolve the absolute path (via
filepath.EvalSymlinks or similar) and verify the resolved path has the skills
root (resolved dir) as its prefix to ensure containment; update the SKILL
loading code path that returns skill.Content accordingly and add a regression
test that creates a symlinked SKILL.md (and a symlinked skill directory) and
asserts those are skipped.
🧹 Nitpick comments (1)
internal/plugins/plugins.go (1)

481-495: 💤 Low value

Consider including Homepage in the formatted output.

formatPluginMetadata renders author, license, and keywords but omits the Homepage field that is parsed at line 369. Including it would provide users with complete metadata visibility in zero plugins listings.

📝 Suggested addition
 func formatPluginMetadata(plugin LoadedPlugin) []string {
 	meta := []string{}
 	if author := formatAuthor(plugin.Author); author != "" {
 		meta = append(meta, "author: "+author)
 	}
 	if plugin.License != "" {
 		meta = append(meta, "license: "+plugin.License)
 	}
+	if plugin.Homepage != "" {
+		meta = append(meta, "homepage: "+plugin.Homepage)
+	}
 	if len(plugin.Keywords) > 0 {
 		meta = append(meta, "keywords: "+strings.Join(plugin.Keywords, ", "))
 	}
 	return meta
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/plugins/plugins.go` around lines 481 - 495, formatPluginMetadata
omits the plugin Homepage field; update the function (formatPluginMetadata) to
check LoadedPlugin.Homepage and when non-empty append "homepage:
"+plugin.Homepage to the meta slice (similar to author/license/keywords) so the
homepage appears in zero plugins listings.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/tools/registry.go`:
- Around line 174-176: The Skill tool registration (NewSkillTool("") in
CoreReadOnlyTools) is fine but skills.Load must block symlink attacks before
this tool is exposed; update skills.Load to detect and reject symlinked SKILL.md
files (use os.Lstat to check ModeSymlink or resolve with filepath.EvalSymlinks
and verify the resolved path is inside the allowed skills.DefaultDir), return an
error when a symlink or an out-of-dir target is found, and ensure NewSkillTool
remains read-only until this validation is in place.

---

Duplicate comments:
In `@internal/skills/skills.go`:
- Around line 116-127: The code builds manifestPath := filepath.Join(dir,
entry.Name(), skillFileName) and reads it without symlink checks; change the
logic in the loop that iterates entries in internal/skills/skills.go (the block
using entries, entry, dir, manifestPath, skillFileName) to first skip any entry
that is a symlink (use os.Lstat and check ModeSymlink) and then, before calling
os.ReadFile, check whether manifestPath itself is a symlink (skip if so) or
resolve the absolute path (via filepath.EvalSymlinks or similar) and verify the
resolved path has the skills root (resolved dir) as its prefix to ensure
containment; update the SKILL loading code path that returns skill.Content
accordingly and add a regression test that creates a symlinked SKILL.md (and a
symlinked skill directory) and asserts those are skipped.

---

Nitpick comments:
In `@internal/plugins/plugins.go`:
- Around line 481-495: formatPluginMetadata omits the plugin Homepage field;
update the function (formatPluginMetadata) to check LoadedPlugin.Homepage and
when non-empty append "homepage: "+plugin.Homepage to the meta slice (similar to
author/license/keywords) so the homepage appears in zero plugins listings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a1142f5c-459f-4a1f-9397-916a7604ce4d

📥 Commits

Reviewing files that changed from the base of the PR and between f3463d6 and 1224a1d.

📒 Files selected for processing (9)
  • internal/plugins/plugins.go
  • internal/plugins/plugins_test.go
  • internal/skills/skills.go
  • internal/skills/skills_test.go
  • internal/specialist/manifest.go
  • internal/tools/registry.go
  • internal/tools/registry_test.go
  • internal/tools/skill.go
  • internal/tools/skill_test.go

Comment thread internal/tools/registry.go
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review — the symlink disclosure (your only actionable item, also raised by @Vasanthdev2004) is fixed on the current head efffc7c: load() resolves the skills root via EvalSymlinks and confines each SKILL.md to it (confineSkillPath), skipping any symlink that escapes the root rather than reading it. Regression test TestLoadSkipsSymlinkedSkillFileEscapingRoot added. The earlier omitempty/alias/default-dir/list items are also in on this head. build/vet/-race/full-suite + windows green.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: approved.

Rereviewed the latest head (efffc7c). The previous skill-loader symlink issue is fixed: SKILL.md is resolved and confined to the resolved skills root before reading, with a regression test for an escaping symlink.

Validation run locally on this head:

  • go test ./internal/skills ./internal/tools ./internal/plugins ./internal/specialist
  • go test ./...

Both passed. GitHub CI smoke checks were green when rechecked; CodeRabbit was still pending at that exact moment.

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the symlink confinement implementation in skills.go
fd -e go skills.go --exec cat {}

# Also check for confineSkillPath and EvalSymlinks usage
rg -n "confineSkillPath|EvalSymlinks|HasPrefix|strings.HasPrefix|filepath.Rel" --type go -C3

Length of output: 97553



@gnanam1990 — the confineSkillPath implementation looks correct. Walked through it against the rest of the codebase:

  • EvalSymlinks(manifestPath) resolves through both a symlinked skill directory and a symlinked SKILL.md, then filepath.Rel(rootReal, real) followed by the ".." / HasPrefix(".."+sep) / IsAbs triple-check is exactly the same guard used in confineGrepFile (grep.go:182), resolveWorkspaceTargetPath (workspace.go:63/128), and sandbox/paths.go:48,80 — fully consistent.
  • os.ReadFile(realPath) reads the EvalSymlinks-resolved path, not the original manifest path — correct.
  • The root fallback to filepath.Abs(dir) when EvalSymlinks(dir) fails keeps confinement stable even on a yet-to-exist directory.
  • filepath.Separator is used in the prefix check so Windows builds handle ..\\ correctly.

Triggering a full review now.


🧠 Learnings used
Learnt from: CR
Repo: Gitlawb/openclaude PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-06-06T00:19:44.234Z
Learning: Verify that the review calls out the risk surface and whether it introduces a blocker if the PR touches auth, provider routing, permissions, outbound network behavior, background execution, startup/config-home behavior, skills/plugins/MCP, CI permissions, or release scripts.

Learnt from: CR
Repo: Gitlawb/openclaude PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-06-04T22:10:40.834Z
Learning: If the PR touches auth, provider routing, permissions, outbound network behavior, background execution, startup/config-home behavior, skills/plugins/MCP, CI permissions, or release scripts, verify that the review calls out the risk surface and whether it introduces a blocker.
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/skills/skills.go`:
- Around line 101-113: confineSkillPath currently only ensures manifestPath is
under rootReal but can return special files; before callers (e.g., the
os.ReadFile(realPath) call) attempt to read, reject non-regular files by
stat-ing the resolved path: call os.Stat on the resolved real path returned by
confineSkillPath (or perform the stat inside confineSkillPath) and check
FileInfo.Mode().IsRegular(); if not regular (FIFO, device, directory, symlink
target that's not a regular file), return false so reads never proceed on
special files.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0365c251-275c-41e4-91b6-09342a0f35f1

📥 Commits

Reviewing files that changed from the base of the PR and between 1224a1d and efffc7c.

📒 Files selected for processing (2)
  • internal/skills/skills.go
  • internal/skills/skills_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/skills/skills_test.go

Comment thread internal/skills/skills.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
internal/skills/skills.go (1)

101-113: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject non-regular in-root SKILL.md targets before reading.

confineSkillPath only enforces path confinement. On Line 163, os.ReadFile(realPath) can still block on FIFO/device files inside the skills root, so a user-controlled skills directory can hang the read-only skill tool. This is a blocker before remote MCP exposure.

🔧 Proposed fix
 func confineSkillPath(rootReal string, manifestPath string) (string, bool) {
 	real, err := filepath.EvalSymlinks(manifestPath)
 	if err != nil {
 		return "", false
 	}
+	info, err := os.Stat(real)
+	if err != nil || !info.Mode().IsRegular() {
+		return "", false
+	}
 	rel, err := filepath.Rel(rootReal, real)
 	if err != nil {
 		return "", false
 	}
 	if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
 		return "", false
 	}
 	return real, true
 }

Based on learnings: if the PR touches skills/plugins/MCP, call out the risk surface and whether it introduces a blocker.

Also applies to: 163-166

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/skills/skills.go` around lines 101 - 113, confineSkillPath currently
only enforces path confinement but allows non-regular targets (e.g.
FIFOs/devices) which later let os.ReadFile hang; update confineSkillPath (or
immediately before the os.ReadFile(realPath) call) to perform an os.Lstat (or
os.Stat) on the resolved path `real` and check fileInfo.Mode().IsRegular(); if
the file is not regular, return false (or surface an error) so the code never
attempts to read non-regular in-root `SKILL.md` targets.
🧹 Nitpick comments (2)
internal/plugins/plugins.go (1)

421-438: 💤 Low value

Minor: Trim called twice per string.

Line 430-431 trims each string twice (once in the condition, once in append).

♻️ Optional micro-optimization
 	for _, item := range items {
-		if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
-			values = append(values, strings.TrimSpace(text))
+		if text, ok := item.(string); ok {
+			trimmed := strings.TrimSpace(text)
+			if trimmed != "" {
+				values = append(values, trimmed)
+			}
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/plugins/plugins.go` around lines 421 - 438, In
coerceMetaStringSlice, avoid calling strings.TrimSpace twice for each item by
trimming once into a local variable (e.g., trimmed := strings.TrimSpace(text))
and then using that trimmed value for the empty check and for append; update the
loop inside coerceMetaStringSlice to use the single-trimmed variable.
internal/tools/registry.go (1)

174-176: Skills/MCP risk surface acknowledged; not a blocker for this PR.

The skill tool reads from a user-controlled directory (ZERO_SKILLS_DIR or XDG skills path). The implementation includes symlink confinement (EvalSymlinks + confineSkillPath per PR objectives) and never builds filesystem paths from model input (exact name match only). The tool is correctly marked read-only. Before exposing this tool to remote clients via MCP serve, confirm that the read-only label is enforced at the MCP boundary. Per PR objectives, MCP wiring is deferred work, so this is noted for future integration but does not block this PR.

Based on learnings, this PR touches skills/plugins/MCP and I've verified the risk surface does not introduce a blocker.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tools/registry.go` around lines 174 - 176, The NewSkillTool is being
registered to read user-controlled skill dirs; ensure that before exposing it
via MCP serve you enforce the read-only label at the MCP boundary by validating
MCP request handling/wiring to respect the tool's read-only intent, confirm the
implementation uses EvalSymlinks and confineSkillPath for symlink confinement
and that no filesystem paths are constructed from model input (only exact name
matches are accepted), and add a checklist or TODO in the MCP wiring code to
verify and enforce read-only access control when integrating skills/plugins/MCP
in the next PR.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@internal/skills/skills.go`:
- Around line 101-113: confineSkillPath currently only enforces path confinement
but allows non-regular targets (e.g. FIFOs/devices) which later let os.ReadFile
hang; update confineSkillPath (or immediately before the os.ReadFile(realPath)
call) to perform an os.Lstat (or os.Stat) on the resolved path `real` and check
fileInfo.Mode().IsRegular(); if the file is not regular, return false (or
surface an error) so the code never attempts to read non-regular in-root
`SKILL.md` targets.

---

Nitpick comments:
In `@internal/plugins/plugins.go`:
- Around line 421-438: In coerceMetaStringSlice, avoid calling strings.TrimSpace
twice for each item by trimming once into a local variable (e.g., trimmed :=
strings.TrimSpace(text)) and then using that trimmed value for the empty check
and for append; update the loop inside coerceMetaStringSlice to use the
single-trimmed variable.

In `@internal/tools/registry.go`:
- Around line 174-176: The NewSkillTool is being registered to read
user-controlled skill dirs; ensure that before exposing it via MCP serve you
enforce the read-only label at the MCP boundary by validating MCP request
handling/wiring to respect the tool's read-only intent, confirm the
implementation uses EvalSymlinks and confineSkillPath for symlink confinement
and that no filesystem paths are constructed from model input (only exact name
matches are accepted), and add a checklist or TODO in the MCP wiring code to
verify and enforce read-only access control when integrating skills/plugins/MCP
in the next PR.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e35a1fa1-bc30-427e-814e-7dbb965e7c79

📥 Commits

Reviewing files that changed from the base of the PR and between f3463d6 and efffc7c.

📒 Files selected for processing (9)
  • internal/plugins/plugins.go
  • internal/plugins/plugins_test.go
  • internal/skills/skills.go
  • internal/skills/skills_test.go
  • internal/specialist/manifest.go
  • internal/tools/registry.go
  • internal/tools/registry_test.go
  • internal/tools/skill.go
  • internal/tools/skill_test.go

…gle-trim metadata

- skills: confineSkillPath now Lstats the resolved path and skips non-regular targets (directory/FIFO/device/socket). A FIFO/device named SKILL.md (or an in-root symlink to one) would otherwise make os.ReadFile block indefinitely — skill is a permission-allow tool over a user-controlled dir. Regression: TestLoadSkipsNonRegularSkillFile (unix-tagged; mkfifo SKILL.md, asserts Load skips it within a timeout rather than hanging). (Major)

- plugins: coerceMetaStringSlice trims each item once into a local var instead of calling strings.TrimSpace twice. (Nitpick)

build/vet/-race/full-suite + GOOS=windows build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Verified each against current code:

  • confineSkillPath non-regular targets (Major) — fixed: it now Lstats the resolved path and skips anything that isn't a regular file (directory/FIFO/device/socket), so a FIFO/device SKILL.md can't make os.ReadFile block. Added TestLoadSkipsNonRegularSkillFile (unix-tagged: mkfifo SKILL.md, asserts Load skips it within a timeout instead of hanging).
  • coerceMetaStringSlice double TrimSpace (Nitpick) — fixed: trims once into a local var.
  • ⏭️ registry.go MCP read-only TODO (Nitpick) — skipping in this PR with reason: the substantive parts are already satisfied — symlink confinement uses EvalSymlinks+confineSkillPath, no filesystem path is built from model input (exact name match only), and skill is readOnlySafety so serve.go's default MCP set (which is CoreReadOnlyTools) only ever exposes it read-only. There is no MCP wiring in this PR to add a TODO to; I'll carry the read-only-enforcement checklist into the MCP wiring PR (MCP hardening: nested-schema passthrough + hung-server timeout #124 / cli wiring).

build/vet/-race/full-suite + windows green. @coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

@gnanam1990 — thanks, that resolves the actionable safety item cleanly. The non-regular-file guard is the important hardening here: resolving through symlinks is only safe if the final target is also constrained to a regular file before os.ReadFile.

The MCP TODO deferral rationale is reasonable for this PR given:

  • the tool remains readOnlySafety,
  • model input is matched by exact skill name rather than used as a filesystem path,
  • CoreReadOnlyTools is the default MCP read-only set,
  • and the actual MCP/CLI wiring is being carried into #124.

I’ll trigger another review on the current head.


✏️ Learnings added
Learnt from: gnanam1990
URL: https://github.com/Gitlawb/zero/pull/123

Timestamp: 2026-06-07T16:01:39.311Z
Learning: In Gitlawb/zero skills/plugins work, MCP read-only-enforcement checklist items for the `skill` tool may be deferred from PR `#123` to the MCP/CLI wiring follow-up PR `#124` when the current PR only registers the read-only `skill` tool in `CoreReadOnlyTools`, uses exact skill-name matching instead of building filesystem paths from model input, and keeps `skill` marked with `readOnlySafety`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: Gitlawb/openclaude PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-06-06T00:19:44.234Z
Learning: Verify that the review calls out the risk surface and whether it introduces a blocker if the PR touches auth, provider routing, permissions, outbound network behavior, background execution, startup/config-home behavior, skills/plugins/MCP, CI permissions, or release scripts.

Learnt from: CR
Repo: Gitlawb/openclaude PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-06-04T22:10:40.834Z
Learning: If the PR touches auth, provider routing, permissions, outbound network behavior, background execution, startup/config-home behavior, skills/plugins/MCP, CI permissions, or release scripts, verify that the review calls out the risk surface and whether it introduces a blocker.

Learnt from: CR
Repo: Gitlawb/openclaude PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-06-04T22:10:40.834Z
Learning: Verify that product, trust-model, routing-default, telemetry/network, and permission-policy changes are not hidden inside unrelated cleanup. Flag the PR if the policy decision needs explicit maintainer alignment.

Learnt from: CR
Repo: Gitlawb/openclaude PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-06-06T00:19:44.234Z
Learning: Verify that product, trust-model, routing-default, telemetry/network, and permission-policy changes are not hidden inside unrelated cleanup. Flag the PR if the policy decision needs explicit maintainer alignment.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants