fix(skills): copy-on-write for agent edits of external skills - #5407
fix(skills): copy-on-write for agent edits of external skills#5407cyb0rgk1tty wants to merge 1 commit into
Conversation
The docs at docs/user-guide/features/skills.md#external-skill-directories state: > External dirs are only scanned for skill discovery. When the agent > creates or edits a skill, it always writes to ~/.hermes/skills/. But the code didn't match. _edit_skill, _patch_skill, _write_file, and _remove_file all use _find_skill to locate a skill (which walks both the profile-local skills dir and every skills.external_dirs entry), then write directly to the discovered path. So an agent calling skill_edit on a skill that lives in an external/shared directory would silently mutate the shared source file, affecting every profile that references it. This is particularly dangerous once multiple profiles share a single skill directory: a single edit in profile A leaks to profiles B, C, D… with no indication. This commit makes the mutation helpers copy-on-write for external skills, matching the documented behavior: * _is_external_skill() and _copy_on_write_to_local() helpers. * _edit_skill / _patch_skill / _write_file / _remove_file now copy the skill into SKILLS_DIR before mutating, and surface a "note" field in the response telling the agent (and the user) that a profile-local override was created. The external source is untouched. * _delete_skill refuses to delete external skills, pointing the agent at skills.disabled (to hide from the current profile) or direct filesystem removal (to unshare entirely). Deleting a local skill is unchanged. * Validation / scan-block rollback paths clean up the CoW copy so no half-applied state lingers on the disk. Tests: 12 new tests cover _is_external_skill, _copy_on_write_to_local, and the copy-on-write paths for edit, patch, write_file, remove_file, and delete. They set HERMES_HOME in addition to SKILLS_DIR so _find_skill actually resolves the fake external dirs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e skills Adds `hermes skills install --shared` to install a skill into `~/.hermes/shared-skills/` instead of the active profile's local `~/.hermes/skills/` directory. When neither `--shared` nor `--local` is passed, the install command shows an interactive scope prompt so the user can choose explicitly. `--yes`/`-y` (non-interactive mode) silently defaults to local. Depends on: - fix(skills): copy-on-write for agent edits of external skills (PR NousResearch#5407) - feat(skills): skills.shared shorthand for cross-profile sharing (PR NousResearch#5535) Changes: - tools/skills_hub.py: add DEFAULT_SHARED_SKILLS + SHARED_HUB_LOCKFILE constants; add target_root/lockfile keyword params to install_from_quarantine() and uninstall_skill() - tools/skills_tool.py: include get_shared_skill_dirs() in all four skill discovery/search functions so shared-installed skills are visible to agents - hermes_cli/skills_hub.py: _prompt_install_scope(), config helpers (_add/_remove_skill_to/from_profile_shared_config()), updated do_install() with shared= param, do_uninstall() auto-detects shared vs local lockfile, do_list() adds Scope column and --source shared filter - hermes_cli/main.py: --shared / --local mutually-exclusive flags on `hermes skills install`; --source shared choice on `hermes skills list` - tests: 18 new tests covering helpers, shared install flow, uninstall scope, and list scope column; existing mocks updated for HubLockFile path kwarg - docs: install scope section in Skills Hub docs Co-Authored-By: cyb0rgk1tty <cyb0rgk1tty@users.noreply.github.com>
|
Thanks for the detailed write-up and the copy-on-write approach, @cyb0rgk1tty — this was a real bug and your analysis was correct at the time you opened the PR. This is an automated hermes-sweeper review. The underlying bug (external-skill mutations silently writing to shared source paths) was fixed on Evidence:
The fix on main takes a different UX approach (hard-refuse with an explanatory error rather than transparent copy-on-write), but the documented contract is satisfied either way. Closing as implemented on main. |
What does this PR do?
The docs at External Skill Directories state:
But the code didn't match.
_edit_skill,_patch_skill,_write_file, and_remove_fileintools/skill_manager_tool.pyall use_find_skillto locate a skill (which walks both the profile-local skills dir and everyskills.external_dirsentry), then write directly to the discovered path. So an agent callingskill_editon a skill that lives in an external directory silently mutates the shared source file, affecting every profile that references it.This is particularly dangerous once multiple profiles point their
external_dirsat a single shared skill directory (a common pattern for teams or multi-profile hosts): a single edit in profile A leaks to profiles B, C, D... with no indication to the agent or the user.This PR makes the mutation helpers copy-on-write for external skills, matching the documented behavior.
Related Issue
Fixes #
Type of Change
Changes Made
tools/skill_manager_tool.py_is_external_skill(skill_path)— returnsTrueif the skill lives outside the profile-localSKILLS_DIR._copy_on_write_to_local(skill_path)— copies an external skill directory (with all supporting files) intoSKILLS_DIR, preserving the skill's directory name. RaisesRuntimeErrorif a local copy already exists (a guard that shouldn't fire in practice because_find_skillreturns the local copy first)._edit_skill: detects external skills, copies them toSKILLS_DIRfirst, then writes the new content to the copy. Scan-block rollback removes the whole copy (nothing was mutated in place). The response gains anotefield explaining that a profile-local override was created._patch_skill: same pattern. Reads the original content from the external source before the copy, then validates the match, size, and frontmatter on the result. Any validation failure cleans up the copy so no half-applied state lingers._write_file: copies the skill before writing the new supporting file; scan-block rollback removes the whole copy._remove_file: copies the skill before removing the file; the original external skill retains the file._delete_skill: refuses to delete external skills — returns a structured error pointing the agent atskills.disabled(to hide from the current profile) or direct filesystem removal (to unshare entirely). Deleting a local skill is unchanged._COPY_ON_WRITE_NOTE_TEMPLATEproduces a consistent agent-facing message so the user understands why the change didn't propagate to other profiles using the shared copy.tests/tools/test_skill_manager_tool.py_is_external_skillfor local and sibling dirs._copy_on_write_to_localhappy path and the "local already exists" guard.note.note.skills.disabledhint; local delete still works.HERMES_HOMEin addition to patchingSKILLS_DIR, because_find_skillresolves external dirs viaget_hermes_home(). The pre-existing tests in this file only patchSKILLS_DIRand therefore already fail onmain(they fall through to the real user's~/.hermes/). I did not touch those — happy to fix them in a separate PR if the maintainers want.website/docs/user-guide/features/skills.md_delete_skillrejection.How to Test
Checklist
Code
fix(skills): ...)Documentation & Housekeeping
website/docs/user-guide/features/skills.md)