Files
hermes-agent/hermes_cli/plugin_validate_desktop.py
ethernet 9f2ba1b74d merge origin/main (779 commits) into ethie/pm-clean
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).

Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.

uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
2026-09-21 00:58:39 -04:00

101 lines
4.6 KiB
Python

"""Static admission lint for a plugin's Desktop surface (``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.
_FORBIDDEN: Tuple[Tuple[str, "re.Pattern[str]"], ...] = (
("prototype patching",
re.compile(r"\b[A-Za-z_$][\w$]*\.prototype\.[\w$]+\s*=[^=]")),
("prototype patching",
re.compile(r"\bObject\.definePropert(?:y|ies)\(\s*[\w$.]+\.prototype\b")),
("prototype patching",
re.compile(r"\b(?:Reflect|Object)\.setPrototypeOf\(|\.__proto__\s*=")),
("dynamic code evaluation",
re.compile(r"(?<![\w$.])eval\(|\bnew\s+Function\(")),
("dynamic import outside the SDK",
re.compile(r"\bimport\(\s*(?!['\"](?:@hermes/plugin-sdk|react)(?:/[\w/-]*)?['\"]\s*\))")),
("script injection",
re.compile(r"createElement\(\s*['\"]script['\"]\s*\)|<script\b")),
)
_COMMENT = re.compile(r"/\*.*?\*/|(?<![:\w])//[^\n]*", re.S)
# A JS regex literal (``/<script[\s\S]*?<\/script>/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 ``<script`` inside a string literal is
# still the payload of an ``innerHTML`` write and keeps firing. The lookbehind keeps division
# (``a / b / c``) from reading as a literal.
_REGEX_LITERAL = re.compile(r"(?<![\w)\]])/(?:[^/\\\n\[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+/[a-z]*")
_MARKUP_RULES = frozenset({"script injection"})
def _mask_regex_literals(source: str) -> str:
return _REGEX_LITERAL.sub(lambda m: " " * len(m.group(0)), source)
def desktop_surface_findings(source: str) -> List[Tuple[str, int]]:
"""Return ``[(rule, line)]`` for every forbidden construct in a plugin.js source."""
stripped = _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), 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 is_desktop_surface(rel_path: str) -> bool:
"""Whether a file is part of the Desktop surface this lint governs: JS under ``desktop/``.
The renderer loads ``desktop/plugin.js`` (and what it imports from 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.
"""
parts = Path(rel_path).parts
return len(parts) > 1 and parts[0] == "desktop" and Path(rel_path).suffix == ".js"
def desktop_surface_hits(plugin_dir: Path) -> List[str]:
"""``["<rule> (<rel>:<line>)", ...]`` over the plugin's Desktop surface files only."""
plugin_dir = Path(plugin_dir)
desktop = plugin_dir / "desktop"
if not desktop.is_dir():
return []
hits: List[str] = []
for js in sorted(desktop.rglob("*.js")):
rel = js.relative_to(plugin_dir).as_posix()
if not is_desktop_surface(rel):
continue
try:
source = js.read_text(encoding="utf-8-sig", errors="replace")
except OSError:
continue
hits.extend(f"{rule} ({rel}:{line})" for rule, line in desktop_surface_findings(source))
return hits
def check_desktop_surface(report, plugin_dir: Path) -> None:
"""Fail the report when ``desktop/*.js`` steps outside the SDK surface; silent when there is none."""
if not (Path(plugin_dir) / "desktop").is_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",
)