pluginforge 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pluginforge
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Application-agnostic plugin framework built on pluggy
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -3,6 +3,12 @@
3
3
  from pluginforge.base import BasePlugin
4
4
  from pluginforge.discovery import CircularDependencyError
5
5
  from pluginforge.manager import PluginManager
6
+ from pluginforge.security import InvalidPluginNameError
6
7
 
7
- __version__ = "0.2.0"
8
- __all__ = ["BasePlugin", "CircularDependencyError", "PluginManager"]
8
+ __version__ = "0.3.0"
9
+ __all__ = [
10
+ "BasePlugin",
11
+ "CircularDependencyError",
12
+ "InvalidPluginNameError",
13
+ "PluginManager",
14
+ ]
@@ -28,6 +28,15 @@ def collect_migrations_dirs(plugins: list[BasePlugin]) -> dict[str, str]:
28
28
  "Plugin '%s' migrations dir does not exist: %s", plugin.name, migrations_dir
29
29
  )
30
30
  continue
31
- migrations[plugin.name] = str(path)
32
- logger.info("Collected migrations for plugin '%s': %s", plugin.name, migrations_dir)
31
+ # Ensure resolved path doesn't escape via symlinks
32
+ resolved = path.resolve()
33
+ if not resolved.is_dir():
34
+ logger.warning(
35
+ "Plugin '%s' migrations dir resolves to non-directory: %s",
36
+ plugin.name,
37
+ resolved,
38
+ )
39
+ continue
40
+ migrations[plugin.name] = str(resolved)
41
+ logger.info("Collected migrations for plugin '%s': %s", plugin.name, resolved)
33
42
  return migrations
@@ -46,6 +46,8 @@ def load_app_config(config_path: str | Path) -> dict[str, Any]:
46
46
  def load_plugin_config(config_dir: str | Path, plugin_name: str) -> dict[str, Any]:
47
47
  """Load plugin-specific configuration from config/plugins/{name}.yaml.
48
48
 
49
+ Validates the plugin name to prevent path traversal attacks.
50
+
49
51
  Args:
50
52
  config_dir: Base config directory (parent of plugins/).
51
53
  plugin_name: Name of the plugin.
@@ -53,6 +55,9 @@ def load_plugin_config(config_dir: str | Path, plugin_name: str) -> dict[str, An
53
55
  Returns:
54
56
  Plugin configuration dict.
55
57
  """
58
+ from pluginforge.security import validate_plugin_name
59
+
60
+ validate_plugin_name(plugin_name)
56
61
  path = Path(config_dir) / "plugins" / f"{plugin_name}.yaml"
57
62
  return load_yaml(path)
58
63
 
@@ -60,6 +65,8 @@ def load_plugin_config(config_dir: str | Path, plugin_name: str) -> dict[str, An
60
65
  def load_i18n(config_dir: str | Path, lang: str) -> dict[str, Any]:
61
66
  """Load i18n strings for a specific language.
62
67
 
68
+ Validates the language code to prevent path traversal attacks.
69
+
63
70
  Args:
64
71
  config_dir: Base config directory (parent of i18n/).
65
72
  lang: Language code (e.g. "en", "de").
@@ -67,5 +74,8 @@ def load_i18n(config_dir: str | Path, lang: str) -> dict[str, Any]:
67
74
  Returns:
68
75
  i18n strings dict.
69
76
  """
77
+ from pluginforge.security import validate_plugin_name
78
+
79
+ validate_plugin_name(lang)
70
80
  path = Path(config_dir) / "i18n" / f"{lang}.yaml"
71
81
  return load_yaml(path)
@@ -135,6 +135,17 @@ class PluginLifecycle:
135
135
  """
136
136
  return self._initialized.get(name)
137
137
 
138
+ def remove_plugin(self, name: str) -> None:
139
+ """Remove a plugin from all lifecycle tracking.
140
+
141
+ Used during hot-reload to clean up before re-instantiation.
142
+
143
+ Args:
144
+ name: Plugin name.
145
+ """
146
+ self._initialized.pop(name, None)
147
+ self._active.pop(name, None)
148
+
138
149
  def is_active(self, name: str) -> bool:
139
150
  """Check if a plugin is currently active.
140
151
 
@@ -1,6 +1,8 @@
1
1
  """Central PluginManager that orchestrates config, discovery, lifecycle, and hooks."""
2
2
 
3
+ import importlib
3
4
  import logging
5
+ import sys
4
6
  from pathlib import Path
5
7
  from collections.abc import Callable
6
8
  from typing import Any
@@ -18,6 +20,7 @@ from pluginforge.discovery import (
18
20
  )
19
21
  from pluginforge.i18n import I18n
20
22
  from pluginforge.lifecycle import PluginLifecycle
23
+ from pluginforge.security import validate_plugin_name
21
24
 
22
25
  logger = logging.getLogger(__name__)
23
26
 
@@ -165,6 +168,8 @@ class PluginManager:
165
168
  plugin: An already instantiated plugin.
166
169
  plugin_config: Optional config dict. If None, loaded from YAML.
167
170
  """
171
+ validate_plugin_name(plugin.name)
172
+
168
173
  if plugin_config is None:
169
174
  plugin_config = self.get_plugin_config(plugin.name)
170
175
 
@@ -208,6 +213,7 @@ class PluginManager:
208
213
  order: Topologically sorted list of plugin names.
209
214
  """
210
215
  for name in order:
216
+ validate_plugin_name(name)
211
217
  cls = plugins[name]
212
218
  plugin = cls()
213
219
  plugin_config = self.get_plugin_config(name)
@@ -358,3 +364,79 @@ class PluginManager:
358
364
  except Exception as e:
359
365
  results[plugin.name] = {"status": "error", "error": str(e)}
360
366
  return results
367
+
368
+ def reload_plugin(self, name: str) -> bool:
369
+ """Hot-reload a plugin: deactivate, re-import module, re-init, activate.
370
+
371
+ The plugin's module is reloaded from disk so code changes take effect
372
+ without restarting the application.
373
+
374
+ Args:
375
+ name: Name of the plugin to reload.
376
+
377
+ Returns:
378
+ True if reload succeeded, False otherwise.
379
+ """
380
+ plugin = self._lifecycle.get_plugin(name)
381
+ if plugin is None:
382
+ logger.warning("Cannot reload unknown plugin '%s'", name)
383
+ return False
384
+
385
+ plugin_cls = type(plugin)
386
+ module_name = plugin_cls.__module__
387
+
388
+ # Deactivate and unregister
389
+ if self._lifecycle.is_active(name):
390
+ self._lifecycle.deactivate_plugin(plugin)
391
+ self._pm.unregister(name=name)
392
+
393
+ # Remove from lifecycle tracking
394
+ self._lifecycle.remove_plugin(name)
395
+
396
+ # Reload the module
397
+ try:
398
+ module = sys.modules.get(module_name)
399
+ if module is not None:
400
+ module = importlib.reload(module)
401
+ plugin_cls = getattr(module, plugin_cls.__name__)
402
+ except Exception as e:
403
+ logger.error("Failed to reload module '%s': %s", module_name, e)
404
+ self._load_errors[name] = f"Failed to reload module: {e}"
405
+ return False
406
+
407
+ # Re-instantiate and activate
408
+ new_plugin = plugin_cls()
409
+ plugin_config = self.get_plugin_config(name)
410
+
411
+ if not self._lifecycle.init_plugin(new_plugin, self._app_config, plugin_config):
412
+ self._load_errors[name] = "Failed to initialize after reload"
413
+ return False
414
+
415
+ if self._pre_activate is not None:
416
+ if not self._pre_activate(new_plugin, plugin_config):
417
+ self._load_errors[name] = "Rejected by pre-activate check after reload"
418
+ return False
419
+
420
+ self._pm.register(new_plugin, name=name)
421
+
422
+ if not self._lifecycle.activate_plugin(new_plugin):
423
+ self._load_errors[name] = "Failed to activate after reload"
424
+ self._pm.unregister(name=name)
425
+ return False
426
+
427
+ logger.info("Reloaded plugin '%s'", name)
428
+ return True
429
+
430
+ def get_extensions(self, extension_point: type) -> list[BasePlugin]:
431
+ """Return all active plugins that implement a given extension point.
432
+
433
+ An extension point is any class or ABC. This method returns all active
434
+ plugins that are instances of that type.
435
+
436
+ Args:
437
+ extension_point: The extension point class to filter by.
438
+
439
+ Returns:
440
+ List of active plugins implementing the extension point.
441
+ """
442
+ return [p for p in self.get_active_plugins() if isinstance(p, extension_point)]
@@ -0,0 +1,55 @@
1
+ """Security utilities for plugin name validation and path traversal prevention."""
2
+
3
+ import logging
4
+ import re
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ # Plugin names must be alphanumeric with underscores/hyphens, max 64 chars
9
+ _PLUGIN_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$")
10
+
11
+
12
+ class InvalidPluginNameError(ValueError):
13
+ """Raised when a plugin name contains invalid characters."""
14
+
15
+
16
+ def validate_plugin_name(name: str) -> None:
17
+ """Validate a plugin name for safety.
18
+
19
+ Plugin names are used to construct file paths (config/plugins/{name}.yaml),
20
+ so they must not contain path separators or traversal sequences.
21
+
22
+ Args:
23
+ name: The plugin name to validate.
24
+
25
+ Raises:
26
+ InvalidPluginNameError: If the name is invalid.
27
+ """
28
+ if not _PLUGIN_NAME_RE.match(name):
29
+ raise InvalidPluginNameError(
30
+ f"Invalid plugin name '{name}'. "
31
+ "Names must start with a letter, contain only letters, digits, "
32
+ "underscores, or hyphens, and be at most 64 characters."
33
+ )
34
+
35
+
36
+ def validate_safe_path(path: str, allowed_base: str) -> bool:
37
+ """Check that a resolved path stays within the allowed base directory.
38
+
39
+ Args:
40
+ path: The path to validate.
41
+ allowed_base: The base directory that the path must stay within.
42
+
43
+ Returns:
44
+ True if the path is safe, False otherwise.
45
+ """
46
+ from pathlib import Path
47
+
48
+ resolved = Path(path).resolve()
49
+ base = Path(allowed_base).resolve()
50
+ try:
51
+ resolved.relative_to(base)
52
+ return True
53
+ except ValueError:
54
+ logger.warning("Path traversal detected: '%s' escapes base '%s'", path, allowed_base)
55
+ return False
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pluginforge"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Application-agnostic plugin framework built on pluggy"
5
5
  authors = ["Asterios Raptis"]
6
6
  license = "MIT"
File without changes
File without changes