Files
hermes-agent/tests/tools/test_profile_gate_env_isolation.py
teknium1 3ed40556ce fix(profiles): a child spawned for another profile no longer inherits the spawner's authorization gates
A `hermes -p B` child built from a process that loaded profile A's env (a gateway, the
dashboard, the post-update fleet restart) started with A's `DISCORD_ALLOWED_CHANNELS`,
`TELEGRAM_GROUP_ALLOWED_CHATS`, `GATEWAY_ALLOW_ALL_USERS`... and enforced them as its
own: gates are not credentials (no secret scrub sees them), a unit-file `Environment=`
or operator export is in no dotenv (no name-list strip sees them), and B's own `.env`
rarely defines the key (its dotenv load never overwrites the inherited value). Observed
as profile B's gateway rejecting every message in B's own channel after a per-profile
restart issued from A (#113270).

- `local_env_policy.is_profile_gate_env` / `strip_profile_gate_env`: gates matched by
  shape (`_ALLOWED_`, `_ALLOW_ALL_`, `_ALLOW_FROM`, `_ALLOW_BOTS`, `_IGNORED_CHANNELS`,
  ...), never `HERMES_*`, so a gate added to any adapter is covered without a second edit.
- `strip_launch_profile_env` drops them on its existing routed-home branch: the seam
  `served_profile_child_env`, the kanban dispatcher, cron workers and the dashboard
  action env already funnel through (the dashboard site now calls it for its target).
  Same-home children keep an operator export.
- `update_restart_recovery._child_environment(profile)`: the one site that bypassed
  every helper (bare `os.environ.copy()` relaunching EVERY profile) strips gates when
  the profile is not the one the updater runs as; the module stays stdlib-only at
  import time.

Live: fresh-process `hermes_cli.update_restart_recovery --stdin` with three gates in
the updater env — base hands all three to profile b's relaunch, fixed hands none and
keeps them for the launch profile.

Refs #113270; supersedes #113308 (@yashraj4, static key list + always-strip; this keeps
same-profile children intact and covers the per-adapter gate set).
2026-09-18 15:11:47 -07:00

70 lines
3.3 KiB
Python

"""A child spawned FOR another profile never inherits this process's authorization gates (#113270).
``DISCORD_ALLOWED_CHANNELS`` / ``TELEGRAM_GROUP_ALLOWED_CHATS`` / ``GATEWAY_ALLOW_ALL_USERS``-class names
decide WHO may talk to the agent. They are not credentials (no secret scrub sees them), a unit-file
``Environment=`` or operator export never appears in the launch ``.env`` (no name list sees them), and
the target profile's own ``.env`` rarely defines them (its dotenv load never overwrites them) — so a
``hermes -p B`` child built from profile A's process enforced A's channel list as its own.
"""
import json
import os
import subprocess
import sys
import pytest
from tools.environments.local import served_profile_child_env
# Gates injected outside any dotenv — the shape a name-list scrub cannot see. Mix of allowlist,
# deny list, allow-all opt-in, bot policy, channel scoping and a plugin adapter's gate.
_GATES = {
"DISCORD_ALLOWED_CHANNELS": "111", "DISCORD_ALLOWED_ROLES": "r1", "DISCORD_IGNORED_CHANNELS": "222",
"DISCORD_ALLOW_BOTS": "all", "TELEGRAM_GROUP_ALLOWED_CHATS": "-100", "SLACK_ALLOWED_CHANNELS": "C1",
"WHATSAPP_GROUP_ALLOW_FROM": "+1555", "GATEWAY_ALLOW_ALL_USERS": "true",
}
_PROBE = "import json,os;print(json.dumps({k:os.environ.get(k) for k in %r}))" % sorted(_GATES)
def _seen_by_child(env: dict) -> set[str]:
out = subprocess.run([sys.executable, "-c", _PROBE], env=env, capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=60)
return {k for k, v in json.loads(out.stdout.strip().splitlines()[-1]).items() if v is not None}
@pytest.fixture
def homes(tmp_path, monkeypatch):
"""Launch home A (the process's HERMES_HOME) and routed home B; gates in the process env only."""
a = tmp_path / ".hermes"
b = a / "profiles" / "b"
b.mkdir(parents=True)
(a / ".env").write_text("A_MARKER=a\n", encoding="utf-8")
(b / ".env").write_text("B_MARKER=b\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(a))
for key, value in _GATES.items():
monkeypatch.setenv(key, value)
return a, b
def test_routed_child_drops_gates_and_same_home_child_keeps_them(homes):
"""The seam every served-profile spawn funnels through: routed target → no gate reaches the child
(observed from INSIDE a real child); same-home target → an operator export is preserved."""
a, b = homes
assert _seen_by_child(served_profile_child_env(base=os.environ, target_home=b)) == set()
assert _seen_by_child(served_profile_child_env(base=os.environ, target_home=a)) == set(_GATES)
def test_update_recovery_child_env_drops_gates_only_for_other_profiles(homes):
"""The updater relaunches EVERY profile from one environment; the per-profile child env must
strip gates for a foreign profile and keep them for the profile the updater itself runs as."""
from hermes_cli import update_restart_recovery as recovery
routed = recovery._child_environment("b")
same = recovery._child_environment("default")
assert not any(key in routed for key in _GATES)
assert all(same[key] == value for key, value in _GATES.items())
# The recovery marker and the gateway-owner scrub are unchanged on both.
for env in (routed, same):
assert env[recovery._RECOVERY_ENV] == "1"
assert not any(marker in env for marker in recovery._GATEWAY_MARKERS)