fix: validate the skill name before opening its lock; key lock files on a digest

Review finding on #112218 (major): `_skill_lock_path` opened `<skills>/.locks/<name>.lock`
before the name was validated, so `skill_manage(action='create', name='a'*300)` raised
OSError (File name too long) and a NUL name raised ValueError instead of the handler's
JSON error, and every rejected name ('../../etc', '') left a residue lock file.

- tools/skill_manager_tool.py: lock filename is sha256(basename).lock (fixed width, no
  filesystem limit reachable; `foo` and `category/foo` still share one lock), the redundant
  `_find_skill` rglob is gone, and `skill_manage` runs `_validate_name` on the name
  (create) / basename (other actions) before the lock is opened.
- '.locks' joins the skills-dir exclusion sets (EXCLUDED_SKILL_DIRS, ledger
  _NON_PACKAGE_TOPS, learning-graph/skill-commands skip parts, curator backup excludes).
- tests: 2 invariants in TestSkillMutationLock (rejected names -> JSON + no .locks residue;
  digest-keyed lock shared across name forms), red on the old head.
This commit is contained in:
teknium1
2026-09-15 14:39:49 -07:00
committed by Teknium
parent 273986f88f
commit 54ed7cbb7b
7 changed files with 38 additions and 10 deletions
+3 -2
View File
@@ -32,9 +32,10 @@ DEFAULT_KEEP = 5
# Never rolled into a snapshot: .hub/ is owned by the skills hub (rolling it back breaks lockfile invariants); .curator_backups
# is the backup dir itself; .git is repository metadata — rolling it back breaks git tracking, and snapshots that include it grow
# with the full history (once backups are committed back, each snapshot contains the prior ones: 38MB of skills inflated to 24GB
# in weeks). The tar filter in ``snapshot_skills`` applies the same set to nested paths, so a nested ``.git`` is skipped too.
# in weeks); .locks holds skill_manage's per-skill lock files — restoring them would swap a lock out from under a waiting
# writer. The tar filter in ``snapshot_skills`` applies the same set to nested paths, so a nested ``.git`` is skipped too.
# See #91449.
_EXCLUDE_TOP_LEVEL = {".curator_backups", ".hub", ".git"}
_EXCLUDE_TOP_LEVEL = {".curator_backups", ".hub", ".locks", ".git"}
# Snapshot id: UTC ISO with colons replaced by dashes (Windows-safe filename); optional ``-NN`` suffix for same-second snapshots.
_ID_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(-\d{2})?$")
+1 -1
View File
@@ -18,7 +18,7 @@ from typing import Any, Optional
from hermes_constants import get_hermes_home
_SKIP_PARTS = {".archive", ".hub", "node_modules", ".git"}
_SKIP_PARTS = {".archive", ".hub", ".locks", "node_modules", ".git"}
_USAGE_TS_KEYS = ("last_activity_at", "last_used_at", "last_viewed_at", "last_patched_at", "created_at")
+1 -1
View File
@@ -323,7 +323,7 @@ def _scaffold_header(
return "\n".join(lines)
_SCAN_SKIP_PARTS = {'.git', '.github', '.hub', '.archive'}
_SCAN_SKIP_PARTS = {'.git', '.github', '.hub', '.archive', '.locks'}
def _scan_skill_md(skill_md: Path, disabled: set, seen_names: set, commands: Dict[str, Dict[str, Any]], resolve_command) -> None:
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
PLATFORM_MAP = {"macos": "darwin", "linux": "linux", "windows": "win32"}
EXCLUDED_SKILL_DIRS = frozenset((
".git", ".github", ".hub", ".archive", ".curator_backups",
".git", ".github", ".hub", ".archive", ".curator_backups", ".locks",
".venv", "venv", "node_modules", "site-packages", "__pycache__",
".tox", ".nox", ".pytest_cache", ".mypy_cache", ".ruff_cache",
))