""" DM Pairing System Code-based approval flow for authorizing new users on messaging platforms: unknown users receive a one-time pairing code that the bot owner approves via the CLI, instead of maintaining static user-ID allowlists. Security properties (OWASP + NIST SP 800-63-4): 8-char codes from a 32-char unambiguous alphabet via ``secrets``, 1-hour expiry, max 3 pending per platform, 1 request per user per 10 min, lockout after 5 failed approvals, chmod 0600 data files, codes never logged. Storage: ~/.hermes/pairing/ """ import contextlib import hashlib import json import logging import os import secrets import tempfile import threading import time from pathlib import Path from typing import Optional from gateway.whatsapp_identity import ( expand_whatsapp_aliases, normalize_whatsapp_identifier, ) from hermes_constants import ( get_default_hermes_root, get_hermes_dir, get_hermes_home, ) from utils import atomic_replace logger = logging.getLogger(__name__) # Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" CODE_LENGTH = 8 CODE_TTL_SECONDS = 3600 # Codes expire after 1 hour RATE_LIMIT_SECONDS = 600 # 1 request per user per 10 minutes LOCKOUT_SECONDS = 3600 # Lockout duration after too many failures MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout # Default (non-profile-scoped) pairing directory. Deliberately ``None``: the # long-lived gateway imports this module at boot, and an eagerly computed path # would freeze whatever HERMES_HOME/profile context existed then, ignoring later # context-local overrides (hermes_constants.set_hermes_home_override) -- so the # gateway and the short-lived ``hermes pairing`` CLI ended up writing different # directories. ``_default_pairing_dir()`` resolves fresh on every call; tests # patch this attribute to a concrete path (``patch("gateway.pairing.PAIRING_DIR", tmp)``) # and a non-``None`` value takes precedence. PAIRING_DIR = None def _default_pairing_dir() -> Path: """Resolve the default pairing directory fresh (see ``PAIRING_DIR``).""" if PAIRING_DIR is not None: return PAIRING_DIR return get_hermes_dir("platforms/pairing", "pairing") # Platform value -> per-platform allowlist env var. When an operator already # runs an allowlist for a platform, approving a pairing code also writes the # user into it (and revoking removes them), so the operator's list stays the # single visible source of truth instead of drifting from an opaque # approved.json. Platforms absent here (or with no allowlist configured) keep # the pairing store as the sole grant record, honored by the authz union. _PLATFORM_ALLOWLIST_ENV = { "telegram": "TELEGRAM_ALLOWED_USERS", "discord": "DISCORD_ALLOWED_USERS", "whatsapp": "WHATSAPP_ALLOWED_USERS", "whatsapp_cloud": "WHATSAPP_CLOUD_ALLOWED_USERS", "slack": "SLACK_ALLOWED_USERS", "signal": "SIGNAL_ALLOWED_USERS", "email": "EMAIL_ALLOWED_USERS", "sms": "SMS_ALLOWED_USERS", "mattermost": "MATTERMOST_ALLOWED_USERS", "matrix": "MATRIX_ALLOWED_USERS", "dingtalk": "DINGTALK_ALLOWED_USERS", "feishu": "FEISHU_ALLOWED_USERS", "wecom": "WECOM_ALLOWED_USERS", "wecom_callback": "WECOM_CALLBACK_ALLOWED_USERS", "weixin": "WEIXIN_ALLOWED_USERS", "bluebubbles": "BLUEBUBBLES_ALLOWED_USERS", "qqbot": "QQ_ALLOWED_USERS", "yuanbao": "YUANBAO_ALLOWED_USERS", } def _allowlist_env_for_platform(platform: str) -> Optional[str]: """Allowlist env var name for ``platform`` (plugin registry fallback), or None.""" platform = (platform or "").lower().strip() env_var = _PLATFORM_ALLOWLIST_ENV.get(platform) if env_var: return env_var try: from gateway.platform_registry import platform_registry entry = platform_registry.get(platform) if entry and entry.allowed_users_env: return entry.allowed_users_env except Exception: pass return None def _split_allowlist(raw: str) -> list: return [uid.strip() for uid in raw.split(",") if uid.strip()] def _platform_uses_whatsapp_identity(platform: str) -> bool: """True for Baileys WhatsApp and Meta Cloud — same phone/JID identity rules.""" return (platform or "").strip().lower() in {"whatsapp", "whatsapp_cloud"} def _normalize_user_id(platform: str, user_id: str) -> str: """Normalize platform-specific user IDs before persisting / comparing them.""" raw_user_id = str(user_id or "").strip() if _platform_uses_whatsapp_identity(platform): return normalize_whatsapp_identifier(raw_user_id) or raw_user_id return raw_user_id def _user_id_aliases(platform: str, user_id: str) -> set[str]: """Return all known equivalent user IDs for auth / allowlist matching.""" raw_user_id = str(user_id or "").strip() if not raw_user_id: return set() aliases = {raw_user_id, _normalize_user_id(platform, raw_user_id)} if _platform_uses_whatsapp_identity(platform): aliases.update(expand_whatsapp_aliases(raw_user_id)) aliases.discard("") return aliases def _user_ids_match(platform: str, left: str, right: str) -> bool: """Return True when two user IDs represent the same principal.""" left_aliases = _user_id_aliases(platform, left) right_aliases = _user_id_aliases(platform, right) return bool(left_aliases and right_aliases and (left_aliases & right_aliases)) def _read_allowlist_env(env_var: str) -> str: """Read a platform allowlist env var through the profile secret scope. Under multiplexing the process env may hold ANOTHER profile's allowlist (first-writer-wins YAML→env bridges), so a scoped miss must return empty rather than borrow the process value. Unscoped callers (single-profile CLI / admin endpoints) keep the legacy ``os.getenv`` read. Writes go through ``hermes_cli.config.save_env_value`` / ``remove_env_value``, which target the active profile's ``.env`` and, under multiplexing, publish into the installed scope mapping rather than the shared ``os.environ``. """ try: from agent.secret_scope import UnscopedSecretError, get_secret try: return (get_secret(env_var) or "").strip() except UnscopedSecretError: pass except Exception: pass return (os.getenv(env_var) or "").strip() def _configured_allowlist(platform: str): """Return ``(env_var, ids)`` for a platform whose allowlist is configured, else None. An unconfigured allowlist means an open gateway: the pairing store stays the sole grant record and we must never lock the gateway by materializing one. """ env_var = _allowlist_env_for_platform(platform) if not env_var: return None current = _read_allowlist_env(env_var) if not current: return None return env_var, _split_allowlist(current) def _sync_allowlist_add(platform: str, user_id: str) -> None: """Add ``user_id`` to the platform allowlist env var IF one is configured.""" configured = _configured_allowlist(platform) if configured is None: return env_var, ids = configured if "*" in ids or str(user_id) in ids: return # Already covered. ids.append(str(user_id)) try: from hermes_cli.config import save_env_value save_env_value(env_var, ",".join(ids)) except Exception: # Best-effort: the pairing store grant still authorizes via the union. pass def _iter_live_gateway_adapters(): """Yield adapters from the in-process GatewayRunner, if one is running.""" try: from gateway.run import _gateway_runner_ref runner = _gateway_runner_ref() except Exception: return if runner is None: return for adapter in (getattr(runner, "adapters", None) or {}).values(): if adapter is not None: yield adapter for mapping in (getattr(runner, "_profile_adapters", None) or {}).values(): for adapter in (mapping or {}).values(): if adapter is not None: yield adapter def _adapter_platform_name(adapter) -> str: value = getattr(getattr(adapter, "platform", None), "value", None) if value: return str(value).strip().lower() return str(getattr(adapter, "name", None) or "").strip().lower() def _purge_allowlist_entries(entries, platform: str, user_id: str): """Drop alias-equivalent allowlist entries while preserving ``*``.""" def keep(entry) -> bool: entry = str(entry) return entry.strip() == "*" or not _user_ids_match(platform, entry, str(user_id)) if isinstance(entries, str): return ",".join(part for part in _split_allowlist(entries) if keep(part)) if isinstance(entries, (set, frozenset)): return {entry for entry in entries if keep(entry)} if isinstance(entries, (list, tuple)): return [entry for entry in entries if keep(entry)] return entries def _sync_live_adapter_allowlist_remove(platform: str, user_id: str) -> None: """Clear revoked principals from in-process adapter allowlist snapshots. Adapters snapshot ``_allow_from`` at construction; when the revoked principal was the sole entry the env key is removed entirely, so intake must not keep authorizing from the stale snapshot until restart. """ platform_name = (platform or "").strip().lower() if not platform_name or not str(user_id or "").strip(): return for adapter in _iter_live_gateway_adapters(): if _adapter_platform_name(adapter) != platform_name: continue if hasattr(adapter, "_allow_from"): with contextlib.suppress(Exception): adapter._allow_from = _purge_allowlist_entries( set(adapter._allow_from or ()), platform_name, user_id ) extra = getattr(getattr(adapter, "config", None), "extra", None) if isinstance(extra, dict) and "allow_from" in extra: with contextlib.suppress(Exception): extra["allow_from"] = _purge_allowlist_entries( extra.get("allow_from"), platform_name, user_id ) def _sync_allowlist_remove(platform: str, user_id: str) -> None: """Remove ``user_id`` (and WhatsApp alias equivalents) from the allowlist. Approve mirrors a normalized phone while revoke is often given a JID or device-suffix form, so matching uses alias rules -- exact-string delete would leave the sender authorized. Also clears in-process adapter snapshots so sole-entry revocation works without a gateway restart. """ configured = _configured_allowlist(platform) if configured is None: return # No allowlist configured — do not touch config-only snapshots. env_var, ids = configured remaining = _purge_allowlist_entries(ids, platform, user_id) if len(remaining) == len(ids): return # Not present. try: from hermes_cli.config import save_env_value, remove_env_value if remaining: save_env_value(env_var, ",".join(remaining)) else: remove_env_value(env_var) except Exception: pass _sync_live_adapter_allowlist_remove(platform, user_id) def _load_json_file(path: Path) -> dict: if path.exists(): try: data = json.loads(path.read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} except (json.JSONDecodeError, OSError): return {} return {} def _merge_pairing_dir(active_dir: Path, alternate_dir: Path) -> None: """Merge split legacy (``pairing``) / new (``platforms/pairing``) data into the active dir. If both exist, approved users in the inactive location must not be silently ignored (they would be asked for a fresh code). """ if not alternate_dir.exists() or active_dir.resolve() == alternate_dir.resolve(): return active_dir.mkdir(parents=True, exist_ok=True) for src in alternate_dir.glob("*.json"): if not src.is_file(): continue dest = active_dir / src.name merged = _load_json_file(src) if not merged: continue current = _load_json_file(dest) before = dict(current) # Active data wins on key conflict; otherwise union the inactive data. merged.update(current) if merged != before: _secure_write(dest, json.dumps(merged, indent=2, ensure_ascii=False)) def _migrate_split_pairing_dirs( *, home: Optional[Path] = None, active: Optional[Path] = None, ) -> None: home = home or get_hermes_home() old_dir = home / "pairing" new_dir = home / "platforms" / "pairing" active = active if active is not None else _default_pairing_dir() alternate = new_dir if active.resolve() == old_dir.resolve() else old_dir _merge_pairing_dir(active, alternate) def _secure_write(path: Path, data: str) -> None: """Write 0600 via temp file + atomic rename so readers never see a partial file.""" path.parent.mkdir(parents=True, exist_ok=True) fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(data) f.flush() os.fsync(f.fileno()) atomic_replace(tmp_path, path) with contextlib.suppress(OSError): # Windows doesn't support chmod the same way os.chmod(path, 0o600) except BaseException: with contextlib.suppress(OSError): os.unlink(tmp_path) raise class PairingStore: """ Manages pairing codes and approved user lists. Data files per platform: ``{platform}-pending.json``, ``{platform}-approved.json``, plus shared ``_rate_limits.json``. With ``profile=""`` storage resolves from that profile's own HERMES_HOME with the same legacy/consolidated layout rules as ``hermes -p pairing ...``, so multiplex gateways and profile-scoped CLI approvals share one whitelist. Without a profile, storage is the global pairing directory for the current HERMES_HOME. """ def __init__(self, profile: Optional[str] = None): # Resolved lazily: tests use a temp HERMES_HOME and may construct the # store before the env is set. if profile: root = get_default_hermes_root() profile_home = root if profile == "default" else root / "profiles" / profile self._dir = get_hermes_dir("platforms/pairing", "pairing", home=profile_home) else: profile_home = None self._dir = _default_pairing_dir() self._dir.mkdir(parents=True, exist_ok=True) # Merge the alternate old/new layout so upgrades cannot split approvals; # a profile store must resolve exactly as ``hermes -p `` does. _migrate_split_pairing_dirs(home=profile_home, active=self._dir) # Protects all read-modify-write cycles: platform adapters run # concurrently in threads sharing one PairingStore. self._lock = threading.RLock() self._profile = profile # for diagnostics / log lines @property def profile(self) -> Optional[str]: """Profile name this store is scoped to, or None for the global store.""" return self._profile def _pending_path(self, platform: str) -> Path: return self._dir / f"{platform}-pending.json" def _approved_path(self, platform: str) -> Path: return self._dir / f"{platform}-approved.json" def _rate_limit_path(self) -> Path: return self._dir / "_rate_limits.json" def _load_json(self, path: Path) -> dict: if path.exists(): try: return json.loads(path.read_text(encoding="utf-8")) except PermissionError as e: # Loud warning: a 0600 file owned by another uid (Docker: # `docker exec` as root writes it, the gosu-dropped gateway # can't read it) would otherwise be swallowed by the OSError # branch and silently leave the user unauthorized. try: st = path.stat() owner_info = f"owner_uid={st.st_uid} mode={oct(st.st_mode)[-4:]}" except OSError: owner_info = "" euid = os.geteuid() if hasattr(os, "geteuid") else "n/a" # no geteuid on Windows logger.warning( "Pairing file %s exists but is not readable as uid=%s (%s; %s). " "If you ran `docker exec hermes pairing approve ...` as root, " "re-run with `docker exec -u hermes ...` and " "chown the existing file to the hermes user, or restart the " "container so the entrypoint can fix ownership.", path, euid, owner_info, e, ) return {} except (json.JSONDecodeError, OSError): return {} return {} def _save_json(self, path: Path, data: dict) -> None: _secure_write(path, json.dumps(data, indent=2, ensure_ascii=False)) # ----- Approved users ----- def is_approved(self, platform: str, user_id: str) -> bool: """Check if a user is approved (paired) on a platform.""" approved = self._load_json(self._approved_path(platform)) return any(_user_ids_match(platform, uid, user_id) for uid in approved) def list_approved(self, platform: str = None) -> list: """List approved users, optionally filtered by platform.""" results = [] platforms = [platform] if platform else self._all_platforms("approved") for p in platforms: approved = self._load_json(self._approved_path(p)) for uid, info in approved.items(): results.append({"platform": p, "user_id": uid, **info}) return results def _approve_user(self, platform: str, user_id: str, user_name: str = "") -> None: """Add a user to the approved list. Must be called under self._lock.""" approved = self._load_json(self._approved_path(platform)) normalized_user_id = _normalize_user_id(platform, user_id) for approved_user_id in [ uid for uid in approved if _user_ids_match(platform, uid, normalized_user_id) ]: del approved[approved_user_id] approved[normalized_user_id] = { "user_name": user_name, "approved_at": time.time(), } self._save_json(self._approved_path(platform), approved) # Mirror the grant into the operator's allowlist when one is configured. _sync_allowlist_add(platform, normalized_user_id) def revoke(self, platform: str, user_id: str) -> bool: """Remove a user from the approved list. Returns True if found.""" path = self._approved_path(platform) with self._lock: approved = self._load_json(path) matching_ids = [ uid for uid in approved if _user_ids_match(platform, uid, user_id) ] if not matching_ids: return False for approved_user_id in matching_ids: del approved[approved_user_id] self._save_json(path, approved) # Keep the allowlist mirror in sync (no-op if added by other means). _sync_allowlist_remove(platform, user_id) return True # ----- Pending codes ----- @staticmethod def _hash_code(code: str, salt: bytes) -> str: """Hash a pairing code with the given salt using SHA-256.""" return hashlib.sha256(salt + code.encode("utf-8")).hexdigest() def _finish_approval( self, platform: str, pending: dict, matched_key: str, matched_entry: dict ) -> dict: """Remove a pending request and approve its user. Must hold self._lock.""" del pending[matched_key] self._save_json(self._pending_path(platform), pending) # A successful approval proves the requester is legitimate, so the # brute-force streak must not carry over: the counter persists in # _rate_limits.json and would otherwise accumulate isolated typos # until a single fresh typo trips a spurious lockout. self._reset_failed_attempts(platform) self._approve_user( platform, matched_entry["user_id"], matched_entry.get("user_name", "") ) return { "user_id": matched_entry["user_id"], "user_name": matched_entry.get("user_name", ""), } def generate_code( self, platform: str, user_id: str, user_name: str = "" ) -> Optional[str]: """Generate a pairing code for a new user. Returns None if the user is rate-limited, the platform hit MAX_PENDING_PER_PLATFORM, or the platform is locked out. Only a salted SHA-256 hash of the code is persisted, never the plaintext. """ with self._lock: self._cleanup_expired(platform) normalized_user_id = _normalize_user_id(platform, user_id) if self._is_locked_out(platform): return None if self._is_rate_limited(platform, user_id): return None pending = self._load_json(self._pending_path(platform)) if len(pending) >= MAX_PENDING_PER_PLATFORM: return None code = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH)) salt = os.urandom(16) # Keyed by a random entry id, not the code itself. pending[secrets.token_hex(8)] = { "hash": self._hash_code(code, salt), "salt": salt.hex(), "user_id": normalized_user_id, "user_name": user_name, "created_at": time.time(), } self._save_json(self._pending_path(platform), pending) self._record_rate_limit(platform, user_id) return code def approve_code(self, platform: str, code: str) -> Optional[dict]: """Approve a pairing code. Adds the user to the approved list. Returns ``{user_id, user_name}`` on success, ``None`` if the code is invalid/expired OR the platform is locked out after ``MAX_FAILED_ATTEMPTS`` failed approvals (disambiguate with ``_is_locked_out``). The code is hashed with each entry's salt and compared in constant time; legacy plaintext-key entries are ignored and pruned at TTL by ``_cleanup_expired``. """ with self._lock: self._cleanup_expired(platform) code = code.upper().strip() # Must run before the pending lookup, or an already-issued valid # code would still be accepted during lockout, nullifying the # brute-force protection. if self._is_locked_out(platform): return None pending = self._load_json(self._pending_path(platform)) # Skip legacy/malformed entries rather than KeyError so an in-place # upgrade over an existing pending.json doesn't crash. for entry_id, entry in pending.items(): if not isinstance(entry, dict) or "salt" not in entry or "hash" not in entry: continue try: salt = bytes.fromhex(entry["salt"]) except ValueError: continue if secrets.compare_digest(self._hash_code(code, salt), entry["hash"]): return self._finish_approval(platform, pending, entry_id, entry) self._record_failed_attempt(platform) return None @staticmethod def looks_like_request_id(value: str) -> bool: """True when ``value`` has the shape of a ``list_pending`` request id. Request ids are ``secrets.token_hex(8)`` (16 hex chars); pairing codes are 8 chars from an uppercase alphabet, so the shapes cannot collide and callers accepting either can dispatch on this. """ value = str(value or "").strip() return len(value) == 16 and all(c in "0123456789abcdefABCDEF" for c in value) def approve_request(self, platform: str, request_id: str) -> Optional[dict]: """Approve a pending request by its server-side request id. Grant path for authenticated admin surfaces (``hermes pairing list``, dashboard/desktop buttons) that must never reveal the DM'd code. Returns ``{user_id, user_name}`` or ``None`` for an unknown/expired id. Unlike :meth:`approve_code` this neither counts toward nor is gated by the brute-force lockout: a request id is only obtainable by an admin already authenticated to this store, so a stale id means "the row you clicked expired", not an attack -- and counting it would let a few GUI clicks lock the operator out of the CLI's code path. """ with self._lock: self._cleanup_expired(platform) request_id = str(request_id or "").strip().lower() if not request_id: return None pending = self._load_json(self._pending_path(platform)) for entry_id, entry in pending.items(): if not isinstance(entry, dict) or "salt" not in entry or "hash" not in entry: continue if secrets.compare_digest(str(entry_id).lower(), request_id): return self._finish_approval(platform, pending, entry_id, entry) return None def list_pending(self, platform: str = None) -> list: """List pending pairing requests, optionally filtered by platform. Codes are hashed and never returned; each entry exposes a server-side ``request_id`` for :meth:`approve_request`. Legacy pre-hash entries report an empty ``request_id`` and age out at TTL. """ results = [] with self._lock: platforms = [platform] if platform else self._all_platforms("pending") for p in platforms: self._cleanup_expired(p) pending = self._load_json(self._pending_path(p)) for entry_id, info in pending.items(): if not isinstance(info, dict): continue created_at = info.get("created_at") if not isinstance(created_at, (int, float)): continue is_modern = isinstance(info.get("hash"), str) and isinstance( info.get("salt"), str ) results.append({ "platform": p, "request_id": str(entry_id) if is_modern else "", "user_id": info.get("user_id", ""), "user_name": info.get("user_name", ""), "age_minutes": int((time.time() - created_at) / 60), }) return results def clear_pending(self, platform: str = None) -> int: """Clear all pending requests. Returns count removed.""" with self._lock: count = 0 platforms = [platform] if platform else self._all_platforms("pending") for p in platforms: pending = self._load_json(self._pending_path(p)) count += len(pending) self._save_json(self._pending_path(p), {}) return count # ----- Rate limiting and lockout ----- def _is_rate_limited(self, platform: str, user_id: str) -> bool: """Check if a user (under any alias) has requested a code too recently.""" limits = self._load_json(self._rate_limit_path()) return any( (time.time() - limits.get(f"{platform}:{alias}", 0)) < RATE_LIMIT_SECONDS for alias in _user_id_aliases(platform, user_id) ) def _record_rate_limit(self, platform: str, user_id: str) -> None: """Record the time of a pairing request for rate limiting.""" limits = self._load_json(self._rate_limit_path()) now = time.time() for alias in _user_id_aliases(platform, user_id): limits[f"{platform}:{alias}"] = now self._save_json(self._rate_limit_path(), limits) def _is_locked_out(self, platform: str) -> bool: """Check if a platform is in lockout due to failed approval attempts.""" limits = self._load_json(self._rate_limit_path()) return time.time() < limits.get(f"_lockout:{platform}", 0) def _record_failed_attempt(self, platform: str) -> None: """Record a failed approval attempt. Triggers lockout after MAX_FAILED_ATTEMPTS.""" limits = self._load_json(self._rate_limit_path()) fail_key = f"_failures:{platform}" fails = limits.get(fail_key, 0) + 1 limits[fail_key] = fails if fails >= MAX_FAILED_ATTEMPTS: limits[f"_lockout:{platform}"] = time.time() + LOCKOUT_SECONDS limits[fail_key] = 0 # Reset counter print(f"[pairing] Platform {platform} locked out for {LOCKOUT_SECONDS}s " f"after {MAX_FAILED_ATTEMPTS} failed attempts", flush=True) self._save_json(self._rate_limit_path(), limits) def _reset_failed_attempts(self, platform: str) -> None: """Clear the failed-approval counter after a success (it tracks *consecutive* failures).""" limits = self._load_json(self._rate_limit_path()) fail_key = f"_failures:{platform}" if limits.get(fail_key): limits[fail_key] = 0 self._save_json(self._rate_limit_path(), limits) # ----- Cleanup ----- def _cleanup_expired(self, platform: str) -> None: """Remove expired pending codes; malformed/legacy entries (no numeric ``created_at``) count as expired.""" path = self._pending_path(platform) pending = self._load_json(path) now = time.time() expired = [ entry_id for entry_id, info in pending.items() if not isinstance(info, dict) or not isinstance(info.get("created_at"), (int, float)) or (now - info["created_at"]) > CODE_TTL_SECONDS ] if expired: for entry_id in expired: del pending[entry_id] self._save_json(path, pending) def _all_platforms(self, suffix: str) -> list: """List all platforms that have data files of a given suffix.""" tail = f"-{suffix}.json" platforms = [f.name.replace(tail, "") for f in self._dir.iterdir() if f.name.endswith(tail)] return [p for p in platforms if not p.startswith("_")]