Files
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

199 lines
8.7 KiB
Python

"""Website access policy helpers for URL-capable tools.
Loads a user-managed website blocklist (``security.website_blocklist`` in ~/.hermes/config.yaml plus
optional shared list files) without the heavier CLI config stack. The parsed policy is cached with a
short TTL so config edits take effect quickly without re-parsing YAML on every URL check.
"""
from __future__ import annotations
import fnmatch
import logging
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from hermes_constants import get_hermes_home
from tools.url_safety import _normalize_hostname as _normalize_host
logger = logging.getLogger(__name__)
_DEFAULT_WEBSITE_BLOCKLIST = {"enabled": False, "domains": [], "shared_files": []}
# Without this cache a 50-URL extract would mean 51 YAML parses of config.yaml.
_CACHE_TTL_SECONDS = 30.0
_cache_lock = threading.Lock()
_cached_policy: Optional[Dict[str, Any]] = None
_cached_policy_path: Optional[str] = None
_cached_policy_time: float = 0.0
class WebsitePolicyError(Exception):
"""Raised when a website policy file is malformed."""
def _normalize_rule(rule: Any) -> Optional[str]:
"""Reduce a rule (bare host, URL, or ``host/path``) to a lowercase host; None for blanks/comments."""
if not isinstance(rule, str) or not (value := rule.strip().lower()) or value.startswith("#"):
return None
if "://" in value:
parsed = urlparse(value)
value = parsed.netloc or parsed.path
return value.split("/", 1)[0].strip().rstrip(".").removeprefix("www.") or None
def _iter_blocklist_file_rules(path: Path) -> List[str]:
"""Rules from a shared blocklist file; missing/unreadable files warn and yield nothing rather than
raising — a bad file path must not disable all web tools."""
try:
raw = path.read_text(encoding="utf-8-sig")
except FileNotFoundError:
logger.warning("Shared blocklist file not found (skipping): %s", path)
return []
except (OSError, UnicodeDecodeError) as exc:
logger.warning("Failed to read shared blocklist file %s (skipping): %s", path, exc)
return []
return [rule for rule in map(_normalize_rule, raw.splitlines()) if rule]
def _require_mapping(value: Any, label: str) -> Dict[str, Any]:
"""``None`` (empty YAML section) counts as an empty mapping; other non-dicts are errors."""
if value is not None and not isinstance(value, dict):
raise WebsitePolicyError(f"{label} must be a mapping")
return value or {}
def _load_policy_config(config_path: Path) -> Dict[str, Any]:
if not config_path.exists():
return dict(_DEFAULT_WEBSITE_BLOCKLIST)
try:
import hermes_yaml as yaml
except ImportError:
logger.debug("ruamel.yaml not installed — website blocklist disabled")
return dict(_DEFAULT_WEBSITE_BLOCKLIST)
try:
config = yaml.safe_load(config_path.read_text(encoding="utf-8-sig")) or {}
except yaml.YAMLError as exc:
raise WebsitePolicyError(f"Invalid config YAML at {config_path}: {exc}") from exc
except OSError as exc:
raise WebsitePolicyError(f"Failed to read config file {config_path}: {exc}") from exc
if not isinstance(config, dict):
raise WebsitePolicyError("config root must be a mapping")
security = _require_mapping(config.get("security", {}), "security")
website_blocklist = _require_mapping(security.get("website_blocklist", {}), "security.website_blocklist")
return {**_DEFAULT_WEBSITE_BLOCKLIST, **website_blocklist}
def _require_type(policy: Dict[str, Any], key: str, kind: type, default: Any) -> Any:
"""Typed policy field; ``None``/empty list values are coerced to ``[]`` for lists only."""
value = policy.get(key, default)
if kind is list:
value = value or []
if not isinstance(value, kind):
kind_name = "boolean" if kind is bool else "list"
raise WebsitePolicyError(f"security.website_blocklist.{key} must be a {kind_name}")
return value
def load_website_blocklist(config_path: Optional[Path] = None) -> Dict[str, Any]:
"""Parsed website blocklist policy (``{"enabled", "rules"}``); cached for ``_CACHE_TTL_SECONDS`` for
the default config path only — an explicit ``config_path`` (tests) bypasses and never populates it."""
global _cached_policy, _cached_policy_path, _cached_policy_time
default_path = get_hermes_home() / "config.yaml"
resolved_path = str(config_path or default_path)
now = time.monotonic()
if config_path is None:
with _cache_lock:
fresh = _cached_policy_path == resolved_path and (now - _cached_policy_time) < _CACHE_TTL_SECONDS
if _cached_policy is not None and fresh:
return _cached_policy
config_path = config_path or default_path
policy = _load_policy_config(config_path)
domains = map(_normalize_rule, _require_type(policy, "domains", list, []))
pairs: List[Tuple[str, str]] = [(p, "config") for p in domains if p]
shared_files = _require_type(policy, "shared_files", list, [])
enabled = _require_type(policy, "enabled", bool, True)
for shared_file in shared_files:
if not isinstance(shared_file, str) or not shared_file.strip():
continue
path = Path(shared_file).expanduser()
path = path if path.is_absolute() else (get_hermes_home() / path).resolve()
pairs += [(normalized, str(path)) for normalized in _iter_blocklist_file_rules(path)]
# dict.fromkeys dedupes (pattern, source) while keeping first-seen order.
result = {"enabled": enabled, "rules": [{"pattern": p, "source": s} for p, s in dict.fromkeys(pairs)]}
if config_path == default_path: # explicit paths are tests — never cache them
with _cache_lock:
_cached_policy, _cached_policy_path, _cached_policy_time = result, resolved_path, now
return result
def _match_host_against_rule(host: str, pattern: str) -> bool:
"""``*.example.com`` rules glob-match; bare hosts match exactly or as a parent domain."""
if not host or not pattern:
return False
if pattern.startswith("*."):
return fnmatch.fnmatch(host, pattern)
return host == pattern or host.endswith(f".{pattern}")
def _extract_host_from_urlish(url: str) -> str:
"""Host of ``url``; schemeless inputs (``example.com/x``) are retried as ``//url``."""
parsed = urlparse(url)
host = _normalize_host(parsed.hostname or parsed.netloc)
if not host and "://" not in url:
parsed = urlparse(f"//{url}")
host = _normalize_host(parsed.hostname or parsed.netloc)
return host
def check_website_access(url: str, config_path: Optional[Path] = None) -> Optional[Dict[str, str]]:
"""``None`` if the URL is allowed by the blocklist policy, else block metadata (host/rule/source/message).
Fails open on policy errors (warn + ``None``) so a config typo can't break all web tools — except with
an explicit ``config_path`` (tests), where errors propagate.
"""
# Fast path: cached policy disabled/empty → no YAML read, no host extraction.
if config_path is None:
with _cache_lock:
if _cached_policy is not None and not _cached_policy.get("enabled"):
return None
host = _extract_host_from_urlish(url)
if not host:
return None
try:
policy = load_website_blocklist(config_path)
except WebsitePolicyError as exc:
if config_path is not None:
raise
logger.warning("Website policy config error (failing open): %s", exc)
return None
except Exception as exc:
logger.warning("Unexpected error loading website policy (failing open): %s", exc)
return None
if not policy.get("enabled"):
return None
for rule in policy.get("rules", []):
pattern, source = rule.get("pattern", ""), rule.get("source", "config")
if _match_host_against_rule(host, pattern):
logger.info("Blocked URL %s — matched rule '%s' from %s", url, pattern, source)
return {
"url": url, "host": host, "rule": pattern, "source": source,
"message": f"Blocked by website policy: '{host}' matched rule '{pattern}' from {source}",
}
return None
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def invalidate_cache() -> None:
"""Force the next ``check_website_access`` call to re-read config."""
global _cached_policy
with _cache_lock:
_cached_policy = None
# ---- END PLUGIN-COMPAT ----