fix(plugin-compat): scan the plugin's real package, never the CWD; override needs literal true

The compat scanner derived a directory from manifest.path by splitting at the
first ':' and falling back to .parent when the result was not a dir. An
entry-point plugin (`vendor_plugin:register`) and a Windows path
(`C:\Users\...`) both collapsed to '.', so a stray .py in the launch directory
was attributed to the plugin and the installed package was never scanned.
After the removal date that disables the wrong plugin. `_scan_root()` now
takes directory manifests verbatim and resolves entry points via
importlib.util.find_spec to the installed package dir; anything unresolvable
scans nothing.

`plugins.allow_deprecated_imports` used bool(), so the YAML string "false"
opened the post-removal bypass. It now requires the literal boolean True, and
summary_lines() says "force-loaded" instead of "DISABLED" when the override is
what kept the plugins running.

Reported-by: ayushnangia (PR #102117 review)
This commit is contained in:
Teknium
2026-09-04 04:56:11 -07:00
parent 38742342b2
commit 957710a119
2 changed files with 62 additions and 10 deletions
+35 -10
View File
@@ -148,7 +148,7 @@ def scan_source(src: str, rel: str, manifest: Dict[str, Dict[str, str]]) -> List
return sorted(set(hits), key=lambda h: (h.file, h.line, h.old))
def scan_plugin(plugin_dir: Path, manifest: Optional[Dict[str, Dict[str, str]]] = None) -> List[Hit]:
def scan_plugin(plugin_dir: Optional[Path], manifest: Optional[Dict[str, Dict[str, str]]] = None) -> List[Hit]:
manifest = load_manifest() if manifest is None else manifest
if not manifest or not plugin_dir or not Path(plugin_dir).is_dir():
return []
@@ -168,6 +168,31 @@ _report_lock = threading.Lock()
_report_cache: Dict[Tuple[str, ...], Dict[str, List[Hit]]] = {}
def _scan_root(manifest) -> Optional[Path]:
"""Directory to scan for ONE manifest, or None when there is nothing safe to scan.
Directory plugins carry their own dir. Entry points carry ``module:attr``: resolve the module
through import metadata to its installed package dir. Never fall back to a relative path — that
made ``C:\\...`` and ``pkg:attr`` scan the CWD and attribute stray files to the plugin.
"""
if getattr(manifest, "source", "") == "bundled" or not getattr(manifest, "path", None):
return None
raw = str(manifest.path)
if getattr(manifest, "source", "") == "entrypoint":
import importlib.util
try:
spec = importlib.util.find_spec(raw.partition(":")[0])
except (ImportError, ValueError):
spec = None
origin = getattr(spec, "origin", None)
if not origin or origin in ("built-in", "frozen"):
return None
p = Path(origin)
return p.parent if p.name == "__init__.py" else None
p = Path(raw)
return p if p.is_dir() else None
def compat_report(manifests=None, *, force: bool = False) -> Dict[str, List[Hit]]:
"""``{plugin_name: hits}`` for every ENABLED external (non-bundled) plugin with at least one hit.
@@ -189,9 +214,7 @@ def compat_report(manifests=None, *, force: bool = False) -> Dict[str, List[Hit]
manifest = load_manifest()
out: Dict[str, List[Hit]] = {}
for m in external:
d = Path(str(m.path).partition(":")[0])
d = d if d.is_dir() else d.parent
hits = scan_plugin(d, manifest)
hits = scan_plugin(_scan_root(m), manifest)
if hits:
out[m.name] = hits
with _report_lock:
@@ -234,10 +257,7 @@ def _write_report_file(report: Dict[str, List[Hit]]) -> None:
def plugin_hits(manifest) -> List[Hit]:
"""Hits for ONE manifest (used by the loader before importing it)."""
if getattr(manifest, "source", "") == "bundled" or not getattr(manifest, "path", None):
return []
d = Path(str(manifest.path).partition(":")[0])
return scan_plugin(d if d.is_dir() else d.parent)
return scan_plugin(_scan_root(manifest))
def allow_deprecated_imports(config: Optional[dict] = None) -> bool:
@@ -246,7 +266,8 @@ def allow_deprecated_imports(config: Optional[dict] = None) -> bool:
if config is None:
from hermes_cli.config import load_config_readonly
config = load_config_readonly()
return bool(((config or {}).get("plugins") or {}).get(ALLOW_KEY, False))
# Literal boolean only: YAML `"false"` / `"no"` must not open the post-removal bypass.
return ((config or {}).get("plugins") or {}).get(ALLOW_KEY, False) is True
except Exception:
return False
@@ -268,7 +289,11 @@ def summary_lines(report: Dict[str, List[Hit]], *, today: Optional[_dt.date] = N
return []
n = len(report)
names = ", ".join(f"{k} ({len(v)})" for k, v in sorted(report.items()))
if removal_in_effect(today):
if removal_in_effect(today) and allow_deprecated_imports():
head = (f"{n} plugin{'s' if n != 1 else ''} force-loaded via plugins.{ALLOW_KEY}: they import paths "
f"removed on {COMPAT_REMOVAL}: {names}")
tail = "Update the plugin(s); the old paths no longer exist. Details: hermes plugins compat"
elif removal_in_effect(today):
head = (f"{n} plugin{'s' if n != 1 else ''} DISABLED: they import paths removed on {COMPAT_REMOVAL}: {names}")
tail = f"Update the plugin(s) or set plugins.{ALLOW_KEY}: true to force-load. Details: hermes plugins compat"
else: