Files
hermes-agent/agent/safe_worker_policy.py
Teknium 749102ac35 fix(worker): ordinary managed workers serve the frozen session config
The owner already hydrates the session's creation-time config into the bootstrap, but
bind_bypass_policy returned without binding anything for ordinary (non-bypass) sessions.
discover_profile_mcp -> tools/mcp_tool_config._load_mcp_config -> load_config() then read the
LIVE config.yaml, so editing an already-selected MCP server's transport after session creation
changed what an existing session's next fresh worker connected to.

bind_worker_policy now binds the snapshot for every worker before any config import:
- ordinary sessions bind it through the new safe_worker_policy._bind_worker_config, which
  makes worker_config_snapshot() return the frozen config while safe_worker_enabled() stays
  False, so plugins, MCP, hooks and providers keep their ordinary behaviour (ignore_user_config
  is NOT set on ordinary sessions);
- bypass sessions bind exactly as before.

Two consequences of the now-frozen readers are handled at their root:
- save_config in a frozen worker rebases the caller's edit (desired vs the snapshot it loaded)
  onto the LIVE raw file (hermes_cli/config_worker_save.py), so an `always` approval or a
  connector enable lands only its own change instead of writing the snapshot back over every
  profile edit made since the session was created;
- the owner overlays the live `command_allowlist` onto the hydrated snapshot of ordinary
  sessions (gateway/session_managed_worker._worker_config): it is approval state, so a grant an
  earlier worker persisted (or one the operator removed by hand) reaches the next worker, as it
  did when the child read the live file.

Red/green (tests/gateway/test_managed_worker_frozen_config.py, real daemon + real stdio MCP):
- test_existing_session_keeps_frozen_mcp_transport_after_profile_edit: on r6-base
  `assert ['echo_LIVE_B'] == ['echo_FROZEN_A']` (existing session followed the edit); green:
  the existing session sees FROZEN_A, a session created after the edit sees LIVE_B.
- test_ordinary_worker_binding_freezes_readers_but_keeps_customizations: load_config /
  read_raw_config / load_user_config_effective / _load_mcp_config all serve the snapshot, the
  plugin still executes, safe_worker_enabled() is False, and a worker save_permanent_allowlist
  keeps the live file's later edits (red on r6-base: no ordinary binding exists).
Safe/config-only controls: tests/agent/test_safe_worker_policy.py and
test_managed_worker_bypass.py unchanged and green (renamed binding import only).

Review: https://github.com/NousResearch/hermes-agent/pull/106742#pullrequestreview-5461749257
2026-10-08 19:08:59 -07:00

81 lines
3.2 KiB
Python

"""Private bootstrap policy for admission-owned workers: the frozen session config every
config reader returns, plus (troubleshooting workers only) the safe/ignore-user-config bypass.
Stdlib-only: hermes_cli's config/env layer imports it, so it ships in the installer's
hermes_cli + i18n-kernel tail and must never import beyond the standard library.
Bind once in a fresh exec, before config/agent/plugin imports. This is not a
sandbox or a public launch switch: only the managed worker bootstrap consumes
the owner's validated assignment. No environment flag grants this policy and
no reset exists; helper threads see the same immutable snapshot.
"""
from dataclasses import dataclass
import json
import os
import sys
from threading import Lock
@dataclass(frozen=True, slots=True)
class _SafeWorkerPolicy:
pid: int
safe_mode: bool
ignore_user_config: bool
config_json: str
_policy: _SafeWorkerPolicy | None = None
_bind_lock = Lock()
_RUNTIME_IMPORTS = frozenset({
"run_agent", "model_tools", "hermes_cli.config", "hermes_cli.env_loader",
"hermes_cli.plugins", "providers", "agent.agent_init",
})
def _bind_safe_worker_policy(*, safe_mode: bool, ignore_user_config: bool,
config: dict) -> _SafeWorkerPolicy:
"""Called by the private worker bootstrap, never by the owner or public CLI."""
if type(safe_mode) is not bool or type(ignore_user_config) is not bool:
raise TypeError("worker bypass policy fields must be booleans")
if not (safe_mode or ignore_user_config):
raise ValueError("ordinary execution binds its config with _bind_worker_config")
return _bind(safe_mode, True, config)
def _bind_worker_config(config: dict) -> _SafeWorkerPolicy:
"""An ordinary managed worker's frozen session config: every config reader returns it (the
session never follows later profile edits), while plugins, MCP, hooks and providers load as
usual — ``safe_worker_enabled()`` stays False. Same one-shot, pre-import rules as bypass."""
return _bind(False, False, config)
def _bind(safe_mode: bool, ignore_user_config: bool, config: dict) -> _SafeWorkerPolicy:
global _policy
if type(config) is not dict:
raise TypeError("worker config must be a JSON object")
serialized = json.dumps(config, allow_nan=False)
with _bind_lock:
if _policy is not None:
raise RuntimeError("worker policy is already bound")
if _RUNTIME_IMPORTS.intersection(sys.modules):
raise RuntimeError("worker policy must bind before runtime imports")
_policy = _SafeWorkerPolicy(os.getpid(), safe_mode, ignore_user_config, serialized)
return _policy
def _current_policy() -> _SafeWorkerPolicy | None:
if _policy is not None and _policy.pid != os.getpid():
raise RuntimeError("worker policy cannot cross a fork; use a fresh exec")
return _policy
def safe_worker_enabled() -> bool:
policy = _current_policy()
return policy is not None and policy.safe_mode
def worker_config_snapshot() -> dict | None:
"""Return detached explicit config; None means the ordinary loader owns it."""
policy = _current_policy()
return json.loads(policy.config_json) if policy is not None else None