mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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
81 lines
3.2 KiB
Python
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
|