Skip to content

fix(skills): same-root duplicate skill names resolve instead of refusing, symlink-view roots stop warning (#112179, salvage #112180) - #113126

Merged
teknium1 merged 4 commits into
mainfrom
fix/b113-skills-mcp-plugins-dupnames
Sep 17, 2026
Merged

teknium1 merged 4 commits into
mainfrom
fix/b113-skills-mcp-plugins-dupnames

Conversation

@teknium1

@teknium1 teknium1 commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

A skill whose name appears twice inside ONE skills root (a symlink-view entry ~/.hermes/skills/demo plus a nested copy ~/.hermes/skills/cat/demo) now loads by its bare name — in skill_view, /skill, and skill bundles — instead of being refused as a collision, and a symlink-view skills root no longer emits a "skill file is outside the trusted skills directory" warning on every load.

  • tools/skills_tool.py::_locate_skill: when every candidate belongs to the same search dir (ownership decided lexically via _owning_search_dir, so a symlinked entry stays with the root that exposes it), rank them — real SKILL.md beats a legacy flat <name>.md, then the shallower path wins — and log the pick at info level. An equal-rank tie or candidates spanning two tiers (project / local / external) still refuse exactly as before, so the anti-shadowing guard from 59da8ec is unchanged.
  • tools/skills_tool.py::_log_security_warnings: the trust check also accepts the lexical (unresolved) path under a configured root, matching what agent/skill_utils.py::normalize_skill_identifier already does ("prefer the lexical path under a trusted root before resolving symlinks"). A genuinely outside file still warns; the injection-pattern check is untouched.
  • Tests (tests/tools/test_skills_tool.py): two invariants per fix — same-root nested copy resolves to the shallower path / equal-rank tie still refuses; symlinked entry is trusted by the root that exposes it / outside file still warns. The two positive tests fail on origin/main.

Validation (live probe, temp HERMES_HOME, real _locate_skill / skill_view / bundle _load_skill_payload)

case before (origin/main) after
demo = skills/demo → symlink to lib + skills/cat/demo copy Ambiguous skill name 'demo': 2 skills match… resolves to skills/demo/SKILL.md; bundle member demo loads (path: demo/SKILL.md) instead of Skills missing (skipped)
shared in local skills/tools/shared AND external_dirs refused refused (control, unchanged)
pdup twice inside the project tier refused refused (control, unchanged)
over in project tier and local project wins project wins (control, unchanged)
trust warning for symlinked skills/demo/SKILL.md skill file is outside the trusted skills directory no warning
trust warning for elsewhere/x/SKILL.md warns warns (control, unchanged)

A/B: swapping origin/main's tools/skills_tool.py back in turns the demo/ctx/bundle rows red again.

Root cause: _locate_skill treated any second candidate as cross-tier shadowing, and the trust check compared only symlink-resolved paths against resolved roots, so a root that exposes skills through symlinks failed both.

Fixes #112179
Salvages #112180 (@ankinow) — cherry-picked 7a155c0, then trimmed in a follow-up commit.

Dropped hunks

  • agent/skill_bundles.py + tests/agent/test_skill_bundles.py (commit d8f5cd2, "bundle discovery survives a non-UTF-8 manifest"): unrelated to Skill names fail to resolve when duplicated inside a single search root (bundles report them as missing) #112179 (separate bug, authored under an agent identity); left for its own PR.
  • test_legacy_flat_md_never_shadows_real_skill: third test on the same rank; the flat-vs-SKILL.md case was re-probed live (ctx row above) and the rank logic is kept.
  • Unreachable fallbacks in _owning_search_dir / _rank_same_root_candidate and the nested _exposed_under helper (behaviour identical).

Infographic

same-root-duplicate-skill-names

Review follow-up

All three majors reproduced on f3c52f5 (probe: tmp HERMES_HOME, skill_view(name); origin/main refused/warned in every case) and are fixed @ 09b7383.

  • [MAJOR] _locate_skill same-root collapse is depth-only — skills/evil/SKILL.md (name: github) resolved over software-development/github. Fixed: collapse now requires _provably_same_skill (one os.path.realpath for the SKILL.md, or byte-identical content); different content keeps the Ambiguous skill name refusal. Probe after fix: skill_view('github') → success: False, 2 matches. Invariant test test_different_skill_with_same_frontmatter_name_in_same_root_refuses (red pre-fix).
  • [MAJOR] nested <pkg>/foo/SKILL.md beat legacy top-level foo.md, log mislabelled foo.md as "nested" — Fixed by the same identity rule (content differs → refuse; identical copies may still collapse). Log now reads identical same-root copies, resolved to … (duplicates: …). Invariant test test_nested_package_skill_does_not_shadow_top_level_legacy_flat_md (red pre-fix).
  • [MAJOR] _log_security_warnings lexical is_relative_to escape made the resolved-path warning unreachable — Fixed: escape removed, check is realpath-only against the resolved registered dirs (as on main). Probe after fix: skills/sym/SKILL.md → /tmp/outside/SKILL.md logs outside the trusted skills directory again. PR tests reshaped: test_symlink_resolving_under_a_registered_dir_is_trusted (target under a registered root → quiet) and test_skill_md_symlinked_to_outside_every_root_still_warns replaces test_genuinely_outside_file_still_warns (red pre-fix).
  • [MINOR] project-tier comment contradiction — Fixed by wording: the collapse can only merge identical copies, so "two different skills WITHIN the project tier still refuse" is now what the comment says.
  • The PR's own test_nested_copy_inside_same_root_does_not_block_bare_name used different bodies for the two copies; it now uses identical content, which is the Skill names fail to resolve when duplicated inside a single search root (bundles report them as missing) #112179 symlink-view + copy case.

Verification: ruff check clean, check-windows-footguns.py --all clean, git diff --check clean, scripts/run_tests.sh tests/tools/test_skill*.py → 29 files, 665 passed, 0 failed.

LERMF and others added 3 commits September 16, 2026 09:57
_locate_skill refused every name with more than one candidate. That is right for
one skill reachable through two tiers (silent shadowing) but wrong for
duplication inside a single search dir: the runtime root is a symlink view of the
library, so a top-level symlink and a nested category copy of the same skill
collided and made the bare name unusable — bundle members were then reported as
missing and skipped.

Candidates are now ranked within one root: a real SKILL.md wins over a legacy
<name>.md, then the shallower path. Cross-tier ambiguity keeps refusing. Ownership
is decided lexically so a symlinked entry is not reclassified into another root.

Trust warnings accept the resolved target of every root entry as well as the
lexical path, so a root that exposes skills through symlinks no longer warns on
every load and the injection-pattern signal stays readable.

Tests: 183 passed (tests/tools/test_skills_tool.py + tests/agent/test_skill_*.py).
The two new trust tests fail against the previous code; the outside-file test
passes on both and guards the invariant.
…shape

Slim redo of the cherry-picked #112180 hunks without changing behaviour:
`_owning_search_dir` / `_rank_same_root_candidate` lose their unreachable
fallbacks (the owning root is chosen by lexical containment, so
`relative_to(root)` cannot fail), the trust check accepts the lexical path
inline instead of through a nested helper, and the WHY-only comments
replace the install-specific narration. Tests trimmed to two invariants
per fix: same-root nested copy resolves to the shallower path, equal-rank
tie still refuses; symlinked entry is trusted by the root that exposes
it, a genuinely outside file still warns. Dropped the legacy flat
`<name>.md` test (the rank still covers it; re-probed live).
@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

ran on 09b7383 — fix: collapse same-root duplicate skills only when provably

⚠️ Warnings

CI timings · View report · View job

Wall time 11m10s vs 6m8s (+82.1%). 5 job(s) slower, 6 faster, 1 unchanged.

  • Python lints / Windows footguns (blocking): +10.0s
  • Python tests / e2e: +7.0s
  • Detect affected areas: -5.0s
  • OS-specific tests / macOS-only tests: -4.0s
  • Check no case-colliding filenames / check-case-collisions: +4.0s

…skill

The same-root ranking added for #112179 was depth-only, so it silently
resolved any two skills sharing a bare name inside one search dir instead
of refusing: a shallower ~/.hermes/skills/evil/SKILL.md with `name: github`
shadowed software-development/github, and a hub package's nested
<pkg>/foo/SKILL.md won over the user's top-level legacy foo.md — the exact
shadowing shape the 'Ambiguous skill name' refusal exists for.

Gate the collapse on identity: candidates must share one resolved SKILL.md
(symlink view) or byte-identical content (copy). Different content keeps the
loud refusal, as on main. The log no longer labels the top-level file as
'nested'; the project-tier comment now says what still refuses there.

_log_security_warnings: drop the lexical is_relative_to escape. skill_view
only ever passes <search_dir>/... paths, so that escape made the resolved
'outside the trusted skills directory' warning unreachable — including for a
SKILL.md symlinked to a file outside every root, which is what it guards.
A symlink is quiet only when its target resolves under a registered dir.

Tests: the two PR tests that encoded the old behaviour are reshaped
(identical-copy collapse; symlink-into-registered-dir quiet), plus one
invariant per finding, each red on the pre-fix code.
@teknium1
teknium1 merged commit ad80cb4 into main Sep 17, 2026
34 checks passed
@teknium1
teknium1 deleted the fix/b113-skills-mcp-plugins-dupnames branch September 17, 2026 00:05
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.

Skill names fail to resolve when duplicated inside a single search root (bundles report them as missing)

2 participants