"""Static admission lint for a plugin's Desktop surface (``plugin.js`` / ``desktop/plugin.js``). A ``plugin.js`` is evaluated as ESM in the Electron renderer realm with the app's full authority (``apps/desktop/src/contrib/runtime-loader.ts`` says so in its header: error isolation only, no capability boundary). The loader accepts that for files the user put on disk; a catalog install is a remote source, so listed plugins must stay inside the SDK surface. This lint refuses the moves that step outside it. It is a tripwire for review, not a sandbox. """ from __future__ import annotations import re from pathlib import Path from typing import List, Tuple # (rule, regex) applied to comment-stripped source; every hit fails the "desktop surface" check. # Prototype writes are not linted: bundled libraries (three.js) set fields on their own classes, and # no regex could tell those from patching a renderer global without misfiring on minified names. # Catalog review reads them by hand. _FORBIDDEN: tuple[tuple[str, re.Pattern[str]], ...] = ( ("dynamic code evaluation", re.compile(r"(?`` (or minting one on the preview partition) skips both. ("preview guest reach", re.compile(r"\b(?:querySelector(?:All)?|closest|getElementsByTagName|createElement)\(\s*[`'\"]webview\b")), ) # Review cues, warned and never refused (catalog rule 8): the raw ``window.hermesDesktop`` native bridge # (files, git, REST, installs) has no folder or profile scoping — ``ctx.files`` / ``ctx.os`` / # ``host.request`` do — yet listed plugins still use it where the SDK has no verb (microphone access, # favicon lookup). And the app's markup selected from a CSS rule string, ``closest()`` or a template # (``[data-tree-tab=…]{display:none}``) is the reach the refused ``document.querySelector`` rule # names, in shapes no refusal regex can tell from a plugin styling SDK components it rendered itself. _REVIEW: tuple[tuple[str, re.Pattern[str]], ...] = ( ("raw bridge window.hermesDesktop", re.compile(r"\b(?:window|globalThis|self)\s*(?:\?\.|\.)\s*hermesDesktop\b")), ("app markup selector", re.compile(r"\[data-(?:slot|tour|sidebar|testid|tree-[\w-]+|pane-host|titlebar-cluster)\b")), ) # String literals are matched first and kept, so a ``/*`` or ``//`` INSIDE a string (a glob such as # ``'**/*.md'``, a URL) cannot open a "comment" that blanks every line up to the next ``*/`` and # hides whatever forbidden construct sits there. _COMMENT = re.compile( r"(\"(?:[^\"\\\n]|\\.)*\"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)" r"|/\*.*?\*/|(? str: return _COMMENT.sub(lambda m: m.group(1) or "\n" * m.group(0).count("\n"), source) # A JS regex literal (``//gi``) matches markup, it cannot inject any: a # feed sanitiser that STRIPS script tags is the opposite of the move the rule refuses. Regex # literals are masked for the markup-shaped rules only; a ``").source``) the constructor is # a string-builder and its literal keeps firing. _REGEX_LITERAL = re.compile(r"(? str: masked = _REGEX_LITERAL.sub(lambda m: " " * len(m.group(0)), source) return _REGEXP_CTOR_MATCHER.sub(lambda m: " " * len(m.group(0)), masked) def desktop_surface_findings(source: str) -> list[tuple[str, int]]: """Return ``[(rule, line)]`` for every forbidden construct in a plugin.js source.""" stripped = _strip_comments(source) no_regex = _mask_regex_literals(stripped) findings: list[tuple[str, int]] = [] for rule, pattern in _FORBIDDEN: haystack = no_regex if rule in _MARKUP_RULES else stripped for match in pattern.finditer(haystack): findings.append((rule, haystack.count("\n", 0, match.start()) + 1)) return sorted(findings, key=lambda f: f[1]) def desktop_review_findings(source: str) -> list[tuple[str, int]]: """``[(cue, line)]`` for every review cue in a plugin.js source, skipping lines a refusal covers.""" stripped = _strip_comments(source) refused = {line for _rule, line in desktop_surface_findings(source)} found = {(cue, stripped.count("\n", 0, m.start()) + 1) for cue, pattern in _REVIEW for m in pattern.finditer(stripped)} return sorted((hit for hit in found if hit[1] not in refused), key=lambda h: (h[1], h[0])) # The Desktop installer's entry rules (apps/desktop/electron/desktop-plugin-install.ts # ``findDesktopEntry``): a repo-root ``plugin.js`` wins and the whole repo root is published as the # plugin folder; otherwise ``desktop/plugin.js`` and its ``desktop/`` tree. Admission must lint every # layout the installer accepts, or a root-layout plugin ships to the renderer unlinted. _ROOT_ENTRY = "plugin.js" _DESKTOP_DIR = "desktop" def _has_root_entry(plugin_dir: Path) -> bool: return (plugin_dir / _ROOT_ENTRY).exists() def is_desktop_surface(rel_path: str, root_entry: bool = False) -> bool: """Whether a file is part of the Desktop surface this lint governs. That is the repo-root ``plugin.js`` (an installer entry on its own), JS under ``desktop/``, and, when the root entry exists (*root_entry*), the root-level JS published beside it. A Node sidecar (``sidecar/*.mjs``), a build script or a ``tests/*.test.mjs`` never runs in the renderer, so a lazy ``import('jszip')`` there is ordinary Node code — running the rules over every ``*.js`` / ``*.mjs`` in a repository reports noise, not a surface violation. Batch tooling should scope with this predicate (or call ``desktop_surface_hits``) instead of ``rglob``-ing the tree. """ path = Path(rel_path) if path.suffix != ".js": return False parts = path.parts if len(parts) == 1: return parts[0] == _ROOT_ENTRY or root_entry return parts[0] == _DESKTOP_DIR def _desktop_surface_files(plugin_dir: Path) -> list[Path]: root_entry = _has_root_entry(plugin_dir) candidates = list(plugin_dir.glob("*.js")) desktop = plugin_dir / _DESKTOP_DIR if desktop.is_dir(): candidates.extend(desktop.rglob("*.js")) return sorted( f for f in candidates if f.is_file() and is_desktop_surface(f.relative_to(plugin_dir).as_posix(), root_entry) ) def desktop_surface_hits(plugin_dir: Path, findings=None) -> list[str]: """``[" (:)", ...]`` over the plugin's Desktop surface files only. *findings* is the per-source rule pass (``desktop_surface_findings`` refusals by default).""" plugin_dir = Path(plugin_dir) hits: list[str] = [] for js in _desktop_surface_files(plugin_dir): rel = js.relative_to(plugin_dir).as_posix() try: source = js.read_text(encoding="utf-8-sig", errors="replace") except OSError: continue hits.extend(f"{rule} ({rel}:{line})" for rule, line in (findings or desktop_surface_findings)(source)) return hits def check_desktop_surface(report, plugin_dir: Path) -> None: """Fail the report when the Desktop surface steps outside the SDK; silent when there is none.""" plugin_dir = Path(plugin_dir) if not ((plugin_dir / _DESKTOP_DIR).is_dir() or _has_root_entry(plugin_dir)): return hits = desktop_surface_hits(plugin_dir) report.add( "desktop surface", not hits, "; ".join(hits[:8]) + (f" (+{len(hits) - 8} more)" if len(hits) > 8 else "") if hits else "stays inside the plugin SDK surface", ) if cues := desktop_surface_hits(plugin_dir, desktop_review_findings): report.warn("desktop surface review (catalog rule 8: SDK surface only): " + "; ".join(cues[:8]) + (f" (+{len(cues) - 8} more)" if len(cues) > 8 else "") + ". Use ctx.files / ctx.os / host.request and SDK slots, or open an issue for the hook you need.")