Skip to content

fix(skills): follow symlinks in skill management - #67748

Open
americodias wants to merge 1 commit into
NousResearch:mainfrom
americodias:fix/repo-backed-skill-resolution
Open

americodias wants to merge 1 commit into
NousResearch:mainfrom
americodias:fix/repo-backed-skill-resolution

Conversation

@americodias

@americodias americodias commented Jul 19, 2026 •

Copy link
Copy Markdown

What does this PR do?

skill_manage used Path.rglob("SKILL.md") in _find_skill() and _find_skill_in_other_profiles(). Unlike the shared iter_skill_index_files() helper, Path.rglob() does not follow directory symlinks. A symlinked skill could therefore appear in skills_list and load through skill_view, but skill_manage reported it as missing.

This PR routes both skill-management lookups through the existing shared iterator. The change is deliberately narrow: it keeps current matching, exclusion, precedence, and mutation behavior, while making traversal consistent with the rest of the skills system.

This supersedes #35244 by preserving its minimal approach and adding the two items requested in maintainer review: platform-safe symlink setup and regression coverage for _find_skill_in_other_profiles(). Credit to @Linux2010 for #35244 and @liuhao1024 for the related #54200 implementation.

Related Issue

Fixes #35184
Closes #54195
Supersedes #35244

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • tools/skill_manager_tool.py
    • replace both skill-discovery rglob() calls with iter_skill_index_files()
    • preserve existing exclusion and basename matching logic
  • tests/tools/test_skill_manager_tool.py
    • verify _find_skill() discovers a skill installed as a directory symlink in an external skills root
    • verify _find_skill_in_other_profiles() discovers a skill below a symlinked category in another profile
    • skip cleanly when the host cannot create directory symlinks

How to Test

  1. Configure an external skills directory containing a directory symlink to a real skill package.

  2. Call skill_manage(action="patch", name="<skill>", old_string="__DOES_NOT_EXIST__", new_string="x") and confirm lookup reaches content matching instead of returning Skill '<skill>' not found.

  3. Run:

    scripts/run_tests.sh tests/tools/test_skill_manager_tool.py -q
    python3 scripts/check-windows-footguns.py --diff origin/main
    uv run --extra dev ruff check .

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — focused canonical suite passes; full-suite green is not claimed
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15.7.7

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — N/A; no public API or configuration change
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A; no config keys changed
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A; existing shared iterator is reused
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — platform-safe symlink helper added; Windows-footgun scan passes
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A; no schema or description change

Screenshots / Logs

scripts/run_tests.sh tests/tools/test_skill_manager_tool.py -q
✓ targeted canonical test run passed

pytest TestFindSkillSymlinks
2 passed in 0.21s

python3 scripts/check-windows-footguns.py --diff origin/main
✓ No Windows footguns found (2 file(s) scanned).

uv run --extra dev ruff check .
All checks passed!

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/skills Skills system (list, view, manage) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 19, 2026
@americodias
americodias force-pushed the fix/repo-backed-skill-resolution branch from ae4b910 to 7058706 Compare July 19, 2026 23:44
@americodias
americodias force-pushed the fix/repo-backed-skill-resolution branch from 7058706 to 719bf5b Compare July 20, 2026 00:02
@americodias americodias changed the title fix: support repo-backed skill resolution fix(skills): follow symlinks in skill management Jul 20, 2026

@teknium1 teknium1 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.

Thanks for the focused symlink-lookup work. The underlying defect is still present on current main: tools/skill_manager_tool.py:638 and :702 use Path.rglob("SKILL.md"), while the established reader-side iterator uses os.walk(..., followlinks=True) in agent/skill_utils.py:821.

Problems

  • The PR also includes commit 8468aaca446889653029092800de894d0f937da3, which adds the unrelated --oneshot-file CLI surface in hermes_cli/_parser.py:116, hermes_cli/main.py, and hermes_cli/oneshot.py. Please keep this PR to the stated skill-management fix.
  • If proposed separately, the new helper's “without following symlinks” claim is not true where os.O_NOFOLLOW is unavailable: hermes_cli/oneshot.py:49-56 then opens and validates the symlink target, and tests/hermes_cli/test_oneshot_file.py:45-47 deliberately does not assert rejection on that path.

Suggested changes

  • Split out 8468aaca; the first commit is a narrow, relevant salvage candidate.
  • Make the separate file-input feature's symlink behavior consistent across supported platforms, or document its platform limitation and test it.

Automated hermes-sweeper review.

Comment thread hermes_cli/_parser.py Outdated
@@ -112,6 +113,17 @@ def build_top_level_parser():
"auto-bypassed. Intended for scripts / pipes."
),
)
oneshot_group.add_argument(

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.

This introduces a new CLI feature unrelated to symlinked skill management. Please move the --oneshot-file implementation and its tests into a separate PR so this fix remains the narrow traversal change described in the PR body.

Comment thread hermes_cli/oneshot.py Outdated
flags = os.O_RDONLY
if hasattr(os, "O_CLOEXEC"):
flags |= os.O_CLOEXEC
if hasattr(os, "O_NOFOLLOW"):

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.

When os.O_NOFOLLOW is unavailable, the subsequent os.open() follows a symlink and fstat() validates the target, contradicting this helper's “without following symlinks” contract. Reject links consistently on supported platforms, or narrow the contract and add coverage for this fallback.

@americodias
americodias force-pushed the fix/repo-backed-skill-resolution branch 2 times, most recently from 8468aac to 719bf5b Compare July 25, 2026 22:43
@americodias

Copy link
Copy Markdown
Author

Split out 8468aaca4 as requested — this PR now carries only the symlink-lookup fix (719bf5b). The oneshot-file feature will be proposed separately, with the O_NOFOLLOW portability concern addressed (consistent behavior or a documented, tested platform limitation).

@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Summary

35 PRs address or reference this issue complex: configurable skill write roots, external-directory mutation, and the recurring mismatch where symlink-aware readers can see skills that rglob-based management and auxiliary paths cannot. The diffs range from already-merged external-dir and reader-discovery fixes through competing skill-manager iterator substitutions; #67748 is the recorded best fix for the remaining management lookup defect across #4759, #35184, #44609, and #54195.

Related pull requests

Duplicates

Manager-lookup duplicates: #35213, #35244, #35299, #44210, #54200, #56420, #56423, #60960, #67748, and #75225, with #67748 the focused superseding implementation for the core issues. Reader-discovery lineage: #8357, #8769, #9253, #11847, #12624, #14230, #14476, #14668, and #14740; configurable-write lineage: #8177, #13996, #21812, #22236, and #23562.

Suggested consolidation

Keep #67748 open with the salvage path of validating its now-focused head and closing #35213, #35244, #35299, #44210, #54200, #56423, and #60960 as duplicates; this does not disregard their keep_open reviews because #67748 carries the requested shared iterator, platform-safe setup, and cross-profile regression while the unrelated commit was removed. Keep #8177 open for the distinct configurable-write-root feature, and keep #75225 open for author action to add the requested manager-level patch regression and document retained delete refusal before deciding whether its #75130-specific framing should collapse into #67748.

Complex graph

flowchart TD
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    I4759(["issue #4759 (closed)"])
    I35184(["issue #35184 (open)"])
    I44609(["issue #44609 (closed)"])
    I54195(["issue #54195 (open)"])
    subgraph Dup35213 ["PRs duplicating each other"]
        P35213["PR #35213 (open)"]
        P35244["PR #35244 (open)"]
        P44210["PR #44210 (open)"]
        P54200["PR #54200 (closed)"]
        P56420["PR #56420 (closed)"]
        P56423["PR #56423 (open)"]
        P60960["PR #60960 (open)"]
        P67748["PR #67748 (open)"]
        P75225["PR #75225 (open)"]
    end
    P67748 -->|best fix| I4759
    P67748 -->|best fix| I35184
    P67748 -->|best fix| I44609
    P67748 -->|best fix| I54195
    class I4759 closed
    class I35184 open
    class I44609 closed
    class I54195 open
    class P35213 open
    class P35244 open
    class P44210 open
    class P54200 closed
    class P56420 closed
    class P56423 open
    class P60960 open
    class P67748 open
    class P75225 open
    class P67748 best
    class P67748 best
    class P67748 best
    class P67748 best
    class P75225 best
    class P67748 target
    click I4759 "https://github.com/NousResearch/hermes-agent/issues/4759"
    click I35184 "https://github.com/NousResearch/hermes-agent/issues/35184"
    click I44609 "https://github.com/NousResearch/hermes-agent/issues/44609"
    click I54195 "https://github.com/NousResearch/hermes-agent/issues/54195"
    click P35213 "https://github.com/NousResearch/hermes-agent/pull/35213"
    click P35244 "https://github.com/NousResearch/hermes-agent/pull/35244"
    click P44210 "https://github.com/NousResearch/hermes-agent/pull/44210"
    click P54200 "https://github.com/NousResearch/hermes-agent/pull/54200"
    click P56420 "https://github.com/NousResearch/hermes-agent/pull/56420"
    click P56423 "https://github.com/NousResearch/hermes-agent/pull/56423"
    click P60960 "https://github.com/NousResearch/hermes-agent/pull/60960"
    click P67748 "https://github.com/NousResearch/hermes-agent/pull/67748"
    click P75225 "https://github.com/NousResearch/hermes-agent/pull/75225"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).

Cross-PR triage: Reviewed 35 pull requests and 9 issues in this complex. Each diff was read against this issue; Assessment working set: 438 kB of PR diffs, 102 kB of issue/PR text, 46 kB of discussion (81 comments), 69 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

@banozz0

banozz0 commented Sep 6, 2026

Copy link
Copy Markdown

I reproduced this lookup approach against current main and found two safety gaps that the existing lookup-only tests do not cover.

  1. Failed atomic batches can corrupt a directory-symlink installation. Discovery returns the lexical symlink path. Batch snapshotting follows that link, but rollback operates on the alias path: a failed one-operation patch can replace the symlink with a real directory, and a failed two-operation batch can leave the first mutation in the shared source while reporting that everything rolled back.

  2. Background curation can misclassify the linked skill as locally owned. is_external_skill_path() resolves the candidate before comparing it with skills.external_dirs. If the symlink target is outside the configured external root, the external check returns false; after the read-before-write condition is satisfied, a curator-managed record can therefore allow an autonomous write to the shared source.

I built and independently reviewed a current-main fix that keeps the lexical alias for discovery, resolves the target only for batch snapshot/restore, and checks normalized lexical containment before resolved containment for external ownership. Public skill_manage(..., operations=[...]) regressions cover:

  • successful in-place patch with the alias preserved;
  • failed single-operation rollback preserving the link and source;
  • failed multi-operation rollback undoing the first source mutation;
  • background-review refusal for the linked external skill.

Verification: focused 4/4 passed; relevant skill-manager/discovery/ownership suite 168/168 passed; independent read-only review PASS with no findings. Reviewed diff SHA-256: 855b99050e78761952aad1c15d2fc2f52ed61f273095f3056fe654efe4f33880.

I have not pushed a competing branch or changed this PR. Happy to provide the reviewed four-file patch if useful; the important part is that the lookup fix lands together with the rollback and ownership safeguards.

@banozz0

banozz0 commented Sep 15, 2026

Copy link
Copy Markdown

The reviewed patch offered above is now a current-main PR: #112321. It carries this PR's substitution (rebased onto _iter_skill_dirs, which both hunks here predate) plus the rollback and ownership safeguards, each with a public skill_manage(operations=[...]) regression. Happy to fold it into this PR instead if the maintainers prefer one head.

This branch has not been deployed

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

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/skills Skills system (list, view, manage) type/bug Something isn't working

Projects

None yet

5 participants