fix(plugins): widen _sanitize_plugin_name for category-namespaced names

Follow-up to PR #28832 — the dashboard plugin routes now accept slashed
names like `observability/langfuse` and `image_gen/openai`, but
`_sanitize_plugin_name` still rejected forward slash and so dashboard
update + remove on those plugins fell through to '404 not found' even
though they exist on disk.

Adds an opt-in `allow_subdir=True` flag that:
- Permits internal forward slashes (category-namespaced plugin keys
  emitted by `_discover_all_plugins`).
- Strips leading and trailing slashes.
- Still rejects `..` and backslash, and still asserts the resolved
  target lives inside `plugins_dir`.

Opted in at the two read-paths that operate on installed plugins:
`_require_installed_plugin` (CLI update/remove) and
`_user_installed_plugin_dir` (dashboard update/remove). The install
path keeps the default (`allow_subdir=False`) because freshly-cloned
plugins always land top-level under `~/.hermes/plugins/<name>/`.

Adds 6 targeted unit tests covering the new flag's allow/reject matrix.
This commit is contained in:
teknium1 2026-05-22 19:48:59 -07:00 committed by Teknium
parent 487c398dcf
commit 8cf977c8b1
2 changed files with 54 additions and 4 deletions

View file

@ -65,6 +65,36 @@ class TestSanitizePluginName:
with pytest.raises(ValueError, match="must not be empty"):
_sanitize_plugin_name("", tmp_path)
# ── allow_subdir=True ──
def test_allow_subdir_accepts_single_slash(self, tmp_path):
target = _sanitize_plugin_name(
"observability/langfuse", tmp_path, allow_subdir=True
)
assert target == (tmp_path / "observability" / "langfuse").resolve()
def test_allow_subdir_strips_leading_trailing_slash(self, tmp_path):
target = _sanitize_plugin_name(
"/image_gen/openai/", tmp_path, allow_subdir=True
)
assert target == (tmp_path / "image_gen" / "openai").resolve()
def test_allow_subdir_still_rejects_dot_dot(self, tmp_path):
with pytest.raises(ValueError, match="must not contain"):
_sanitize_plugin_name("foo/../bar", tmp_path, allow_subdir=True)
def test_allow_subdir_still_rejects_backslash(self, tmp_path):
with pytest.raises(ValueError, match="must not contain"):
_sanitize_plugin_name("foo\\bar", tmp_path, allow_subdir=True)
def test_allow_subdir_rejects_empty_after_strip(self, tmp_path):
with pytest.raises(ValueError, match="must not be empty"):
_sanitize_plugin_name("///", tmp_path, allow_subdir=True)
def test_allow_subdir_resolves_inside_plugins_dir(self, tmp_path):
target = _sanitize_plugin_name("a/b/c", tmp_path, allow_subdir=True)
assert target.is_relative_to(tmp_path.resolve())
# ── _resolve_git_url ──────────────────────────────────────────────────────