Skip to content

Commit ce64794

Browse files
fix(plugin): validate installed plugin names and safe uninstall
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 074ecf8 commit ce64794

File tree

2 files changed

+131
-49
lines changed

2 files changed

+131
-49
lines changed

openhands-sdk/openhands/sdk/plugin/installed.py

Lines changed: 95 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from __future__ import annotations
4646

4747
import json
48+
import re
4849
import shutil
4950
from datetime import UTC, datetime
5051
from pathlib import Path
@@ -66,6 +67,19 @@
6667
# Metadata file for tracking installed plugins
6768
INSTALLED_METADATA_FILE = ".installed.json"
6869

70+
PLUGIN_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
71+
72+
73+
def _validate_plugin_name(name: str) -> None:
74+
"""Validate plugin name is Claude-like kebab-case.
75+
76+
This protects filesystem operations (install/uninstall) from path traversal.
77+
"""
78+
if not PLUGIN_NAME_PATTERN.fullmatch(name):
79+
raise ValueError(
80+
f"Invalid plugin name. Expected kebab-case like 'my-plugin' (got {name!r})."
81+
)
82+
6983

7084
class InstalledPluginInfo(BaseModel):
7185
"""Information about an installed plugin.
@@ -222,6 +236,7 @@ def install_plugin(
222236
# Load the plugin to get its metadata
223237
plugin = Plugin.load(fetched_path)
224238
plugin_name = plugin.name
239+
_validate_plugin_name(plugin_name)
225240

226241
# Check if already installed
227242
install_path = installed_dir / plugin_name
@@ -265,7 +280,8 @@ def uninstall_plugin(
265280
) -> bool:
266281
"""Uninstall a plugin by name.
267282
268-
Removes the plugin directory and updates the metadata file.
283+
Only plugins tracked in the installed plugins metadata file can be uninstalled.
284+
This avoids deleting arbitrary directories in the installed plugins directory.
269285
270286
Args:
271287
name: Name of the plugin to uninstall.
@@ -281,25 +297,27 @@ def uninstall_plugin(
281297
... else:
282298
... print("Plugin was not installed")
283299
"""
300+
_validate_plugin_name(name)
301+
284302
if installed_dir is None:
285303
installed_dir = get_installed_plugins_dir()
286304

287-
plugin_path = installed_dir / name
288-
289-
# Check if plugin exists
290-
if not plugin_path.exists():
305+
metadata = _load_metadata(installed_dir)
306+
if name not in metadata.plugins:
291307
logger.warning(f"Plugin '{name}' is not installed")
292308
return False
293309

294-
# Remove plugin directory
295-
logger.info(f"Uninstalling plugin '{name}' from {plugin_path}")
296-
shutil.rmtree(plugin_path)
310+
plugin_path = installed_dir / name
311+
if plugin_path.exists():
312+
logger.info(f"Uninstalling plugin '{name}' from {plugin_path}")
313+
shutil.rmtree(plugin_path)
314+
else:
315+
logger.warning(
316+
f"Plugin '{name}' was tracked but its directory is missing: {plugin_path}"
317+
)
297318

298-
# Update metadata
299-
metadata = _load_metadata(installed_dir)
300-
if name in metadata.plugins:
301-
del metadata.plugins[name]
302-
_save_metadata(metadata, installed_dir)
319+
del metadata.plugins[name]
320+
_save_metadata(metadata, installed_dir)
303321

304322
logger.info(f"Successfully uninstalled plugin '{name}'")
305323
return True
@@ -310,9 +328,9 @@ def list_installed_plugins(
310328
) -> list[InstalledPluginInfo]:
311329
"""List all installed plugins.
312330
313-
Returns information about all plugins installed in the installed plugins
314-
directory. This reads from the metadata file and verifies that the
315-
plugin directories still exist.
331+
This function is self-healing: it may update the installed plugins metadata
332+
file to remove entries whose directories were deleted, and to add entries for
333+
plugin directories that were manually copied into the installed dir.
316334
317335
Args:
318336
installed_dir: Directory for installed plugins.
@@ -332,47 +350,69 @@ def list_installed_plugins(
332350
return []
333351

334352
metadata = _load_metadata(installed_dir)
353+
metadata_changed = False
335354
installed_plugins: list[InstalledPluginInfo] = []
336355

337-
# Verify each plugin still exists and collect info
356+
# Verify each tracked plugin still exists and collect info.
338357
for name, info in list(metadata.plugins.items()):
358+
try:
359+
_validate_plugin_name(name)
360+
except ValueError as e:
361+
logger.warning(f"Invalid tracked plugin name {name!r}, removing: {e}")
362+
del metadata.plugins[name]
363+
metadata_changed = True
364+
continue
365+
339366
plugin_path = installed_dir / name
340367
if plugin_path.exists():
341368
installed_plugins.append(info)
342-
else:
343-
# Plugin directory was removed externally, clean up metadata
344-
logger.warning(f"Plugin '{name}' directory missing, removing from metadata")
345-
del metadata.plugins[name]
369+
continue
346370

347-
# Save cleaned metadata if any plugins were removed
348-
if len(installed_plugins) != len(metadata.plugins):
349-
_save_metadata(metadata, installed_dir)
371+
logger.warning(f"Plugin '{name}' directory missing, removing from metadata")
372+
del metadata.plugins[name]
373+
metadata_changed = True
350374

351-
# Also check for plugins that exist but aren't in metadata
352-
# (e.g., manually copied plugins)
375+
# Discover plugins that exist but aren't in metadata (e.g., manual copies).
353376
for item in installed_dir.iterdir():
354-
if item.is_dir() and item.name not in metadata.plugins:
355-
if item.name.startswith("."):
356-
continue # Skip hidden directories
357-
try:
358-
plugin = Plugin.load(item)
359-
info = InstalledPluginInfo(
360-
name=plugin.name,
361-
version=plugin.version,
362-
description=plugin.description,
363-
source="local", # Unknown source
364-
installed_at=datetime.now(UTC).isoformat(),
365-
install_path=str(item),
366-
)
367-
installed_plugins.append(info)
368-
# Add to metadata for future reference
369-
metadata.plugins[plugin.name] = info
370-
logger.info(f"Discovered untracked plugin: {plugin.name}")
371-
except Exception as e:
372-
logger.debug(f"Skipping directory {item}: {e}")
373-
374-
# Save if we discovered new plugins
375-
_save_metadata(metadata, installed_dir)
377+
if not item.is_dir() or item.name.startswith("."):
378+
continue
379+
if item.name in metadata.plugins:
380+
continue
381+
382+
try:
383+
_validate_plugin_name(item.name)
384+
except ValueError:
385+
logger.debug(f"Skipping directory with invalid plugin name: {item}")
386+
continue
387+
388+
try:
389+
plugin = Plugin.load(item)
390+
except Exception as e:
391+
logger.debug(f"Skipping directory {item}: {e}")
392+
continue
393+
394+
if plugin.name != item.name:
395+
logger.warning(
396+
"Skipping plugin directory because manifest name doesn't match "
397+
f"directory name: dir={item.name!r}, manifest={plugin.name!r}"
398+
)
399+
continue
400+
401+
info = InstalledPluginInfo(
402+
name=plugin.name,
403+
version=plugin.version,
404+
description=plugin.description,
405+
source="local",
406+
installed_at=datetime.now(UTC).isoformat(),
407+
install_path=str(item),
408+
)
409+
installed_plugins.append(info)
410+
metadata.plugins[item.name] = info
411+
metadata_changed = True
412+
logger.info(f"Discovered untracked plugin: {plugin.name}")
413+
414+
if metadata_changed:
415+
_save_metadata(metadata, installed_dir)
376416

377417
return installed_plugins
378418

@@ -425,6 +465,8 @@ def get_installed_plugin(
425465
>>> if info:
426466
... print(f"Installed from {info.source} at {info.installed_at}")
427467
"""
468+
_validate_plugin_name(name)
469+
428470
if installed_dir is None:
429471
installed_dir = get_installed_plugins_dir()
430472

@@ -447,7 +489,9 @@ def update_plugin(
447489
"""Update an installed plugin to the latest version.
448490
449491
Re-fetches the plugin from its original source and reinstalls it.
450-
The original source and ref are preserved from the installation metadata.
492+
493+
This always updates to the latest version available from the original source
494+
(i.e., it does not preserve a pinned ref).
451495
452496
Args:
453497
name: Name of the plugin to update.
@@ -465,6 +509,8 @@ def update_plugin(
465509
>>> if info:
466510
... print(f"Updated to v{info.version}")
467511
"""
512+
_validate_plugin_name(name)
513+
468514
if installed_dir is None:
469515
installed_dir = get_installed_plugins_dir()
470516

tests/sdk/plugin/test_installed_plugins.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,18 @@ def test_install_from_github(
223223
assert info.source == "github:owner/sample-plugin"
224224
assert info.resolved_ref == "abc123def456"
225225

226+
def test_install_invalid_plugin_name_raises_error(
227+
self, sample_plugin_dir: Path, installed_dir: Path
228+
):
229+
"""Test that installing a plugin with an invalid manifest name fails."""
230+
manifest_path = sample_plugin_dir / ".plugin" / "plugin.json"
231+
manifest = json.loads(manifest_path.read_text())
232+
manifest["name"] = "bad_name" # not kebab-case
233+
manifest_path.write_text(json.dumps(manifest))
234+
235+
with pytest.raises(ValueError, match="Invalid plugin name"):
236+
install_plugin(source=str(sample_plugin_dir), installed_dir=installed_dir)
237+
226238

227239
class TestUninstallPlugin:
228240
"""Tests for uninstall_plugin function."""
@@ -249,6 +261,30 @@ def test_uninstall_nonexistent_plugin(self, installed_dir: Path):
249261
result = uninstall_plugin("nonexistent", installed_dir=installed_dir)
250262
assert result is False
251263

264+
def test_uninstall_untracked_plugin_does_not_delete(
265+
self, sample_plugin_dir: Path, installed_dir: Path
266+
):
267+
"""Test that uninstall refuses to delete untracked plugin directories."""
268+
import shutil
269+
270+
dest = installed_dir / "untracked-plugin"
271+
shutil.copytree(sample_plugin_dir, dest)
272+
273+
manifest_path = dest / ".plugin" / "plugin.json"
274+
manifest = json.loads(manifest_path.read_text())
275+
manifest["name"] = "untracked-plugin"
276+
manifest_path.write_text(json.dumps(manifest))
277+
278+
result = uninstall_plugin("untracked-plugin", installed_dir=installed_dir)
279+
280+
assert result is False
281+
assert dest.exists()
282+
283+
def test_uninstall_invalid_name_raises_error(self, installed_dir: Path):
284+
"""Test that invalid plugin names are rejected."""
285+
with pytest.raises(ValueError, match="Invalid plugin name"):
286+
uninstall_plugin("../evil", installed_dir=installed_dir)
287+
252288

253289
class TestListInstalledPlugins:
254290
"""Tests for list_installed_plugins function."""

0 commit comments

Comments
 (0)