Files
hermes-agent/hermes_cli/gateway_migrate_guards.py
T
teknium1 0abfd1105c fix(migrate): hermes update refuses to fold cross-user / cross-scope gateways; auto_multiplex_migration opt-out
Reshape the two salvaged commits onto current main (#109954):

- Move the boundary guard out of the gateway_migrate facade into a new sibling
  hermes_cli/gateway_migrate_guards.py as a table of guard functions
  (_AUTO_MIGRATION_GUARDS: service domain, UNIX user, HERMES_HOME tree) plus the
  identity resolver. The facade grows by ~20 lines only (uid/runtime_home on
  ProfileGateway, one seam, the hook wiring).
- Compare uids, not strings: live pid owner via /proc (ps fallback only on
  macOS, where /proc does not exist), else the system unit's User= via
  _read_systemd_user_from_unit (root when absent), else the home directory's
  owner. None means unknown and never blocks.
- The home-tree guard reads the HERMES_HOME the installed unit pins, not the
  directory the plan enumerated: that is where the gateway really runs and is
  exactly the "stale copies under profiles/" shape from the report.
- When the default is detached, a service-managed secondary is a different
  domain for the AUTO path (it must not elect the secondary's manager); the
  explicit command keeps electing it as before.
- The explicit command surfaces the same findings as notices (dry run shows
  them) and is never blocked by them; only the update hook refuses.
- Rename the opt-out key to gateway.auto_multiplex_migration (nested only, no
  top-level alias) and read it before a plan is built, so false prints nothing
  and touches nothing. The explicit command ignores it.
- Tests trimmed to the invariants: one parametrized boundary test that exercises
  the real hook end to end (refuses, touches nothing, dry run shows the notice),
  one "same user / same scope still migrates" control, one opt-out test.
- Docs: boundary table + renamed opt-out section in multi-profile-gateways.md;
  one line in hermes_cli/AGENTS.md.

Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
Co-authored-by: Athena <athena@olympus.local>
2026-09-13 14:48:09 -07:00

146 lines
6.3 KiB
Python

"""Boundaries the AUTOMATIC multiplex migration (``hermes update``) must not cross, and the opt-out.
The multiplexer replaces a kernel-enforced boundary (separate UNIX users, separate service domains,
separate HERMES_HOME trees) with in-process isolation. An operator may choose that with
``hermes gateway migrate --multiplex``; an unattended update hook must not choose it for them.
``build_migration_plan`` records the same findings as NOTICES so a dry run shows them; only
:func:`maybe_auto_migrate_after_update` treats them as blockers (#109954).
"""
from __future__ import annotations
import contextlib
import os
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional
if TYPE_CHECKING:
from hermes_cli.gateway_migrate import MigrationPlan, ProfileGateway
# --------------------------------------------------------------------------- identity resolution
def _pid_uid(pid: int) -> Optional[int]:
"""Owner uid of a live process: ``/proc`` where it exists, ``ps`` on macOS; None when unknown."""
with contextlib.suppress(OSError):
return os.stat(f"/proc/{pid}").st_uid
from hermes_cli.gateway import is_macos
if not is_macos():
return None
with contextlib.suppress(OSError, ValueError, subprocess.SubprocessError):
result = subprocess.run(["ps", "-o", "uid=", "-p", str(pid)], capture_output=True, text=True, encoding="utf-8",
check=False, timeout=2)
if result.returncode == 0 and result.stdout.strip():
return int(result.stdout.strip())
return None
def _system_unit_uid(unit_path: Path) -> Optional[int]:
"""uid a system unit runs as: its ``User=`` (root when absent); None when the name is unknown."""
from hermes_cli.gateway import _read_systemd_user_from_unit
user = _read_systemd_user_from_unit(unit_path)
if user is None:
return 0
import pwd
with contextlib.suppress(KeyError):
return pwd.getpwnam(user).pw_uid
return None
def gateway_identity(home: Path, pid: Optional[int], service: Optional[tuple[str, bool]]) -> tuple[Optional[int], Path]:
"""``(uid, runtime_home)`` of the gateway that serves ``home``.
uid: the live process owner, else the system unit's ``User=``, else the owner of the profile
directory (user-scope systemd / launchd / detached gateways run as the account that owns it).
None means unknown — never a different user. runtime_home: the HERMES_HOME the installed unit
pins, which is where the gateway really runs; ``home`` when there is no unit or no pin.
"""
from hermes_cli.gateway import _hermes_home_pinned_by_unit, get_systemd_unit_path
from hermes_cli.gateway_migrate import _home_env
uid: Optional[int] = _pid_uid(pid) if pid is not None else None
runtime_home = home
if service is not None and service[0] == "systemd":
with _home_env(home):
unit_path = get_systemd_unit_path(system=service[1])
pinned = _hermes_home_pinned_by_unit(unit_path)
if pinned:
runtime_home = Path(pinned).expanduser()
if uid is None and service[1]:
uid = _system_unit_uid(unit_path)
if uid is None:
with contextlib.suppress(OSError):
uid = home.stat().st_uid
return uid, runtime_home
# --------------------------------------------------------------------------- guards
def _service_label(profile: ProfileGateway) -> str:
return profile.service_label() if profile.service is not None else "no service manager (detached)"
def _guard_service_domain(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]:
"""Different manager or scope than the default gateway (system vs user systemd, launchd vs systemd,
or any service when the default is detached: the auto path never elects a secondary's manager)."""
if profile.service == plan.default.service:
return None
return (f"Profile '{profile.name}' runs under {_service_label(profile)} while the default gateway "
f"runs under {_service_label(plan.default)}: a different service domain is not folded automatically.")
def _guard_unix_user(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]:
default_uid = plan.default.uid
if default_uid is None or profile.uid is None or profile.uid == default_uid:
return None
return (f"Profile '{profile.name}' runs as uid {profile.uid} while the default gateway runs as uid "
f"{default_uid}: a UNIX privilege boundary is not folded automatically.")
def _guard_home_tree(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]:
profiles_root = (plan.default_home / "profiles").resolve()
runtime_home = (profile.runtime_home or profile.home).resolve()
if runtime_home.is_relative_to(profiles_root):
return None
return (f"Profile '{profile.name}' runs with HERMES_HOME={runtime_home}, outside {profiles_root}: "
f"the multiplexer would serve {profile.home} instead of the live home.")
_AUTO_MIGRATION_GUARDS: tuple[Callable[[MigrationPlan, ProfileGateway], Optional[str]], ...] = (
_guard_service_domain,
_guard_unix_user,
_guard_home_tree,
)
def auto_migration_blockers(plan: MigrationPlan) -> list[str]:
"""Every boundary a standalone secondary sits behind; empty when the fleet is one user, one service
domain, one profiles/ tree — the only shape ``hermes update`` may fold on its own."""
return [
finding
for profile in plan.standalone_secondaries
for guard in _AUTO_MIGRATION_GUARDS
if (finding := guard(plan, profile)) is not None
]
# --------------------------------------------------------------------------- opt-out
def auto_migration_opted_out(default_home: Path) -> bool:
"""``gateway.auto_multiplex_migration: false`` in the DEFAULT profile's config.yaml. Absent means
opted in (the ``DEFAULT_CONFIG`` value); only the nested key counts, there is no top-level alias."""
cfg_path = default_home / "config.yaml"
if not cfg_path.exists():
return False
from hermes_cli.config import read_user_config_raw
cfg = read_user_config_raw(cfg_path) or {}
gateway_section = cfg.get("gateway")
if not isinstance(gateway_section, dict):
return False
value = gateway_section.get("auto_multiplex_migration")
return value is not None and not bool(value)