diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index a234213e32caf..6bf8c8f8591e7 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -6368,9 +6368,8 @@ def _perform_api_call(next_api_kwargs): }) agent._session_messages = messages logger.info( - "kanban stop-loop nudge issued (attempt %d) task=%s", + "kanban stop-loop nudge issued (attempt %d)", agent._kanban_stop_nudges, - os.environ.get("HERMES_KANBAN_TASK", ""), ) agent._emit_status( "⚠️ Kanban worker tried to exit without " diff --git a/agent/delegation_context.py b/agent/delegation_context.py index b80bbbe00fbf5..55400e121e496 100644 --- a/agent/delegation_context.py +++ b/agent/delegation_context.py @@ -17,6 +17,11 @@ default=False, ) +_KANBAN_WORKER_OWNER: ContextVar[bool] = ContextVar( + "hermes_kanban_worker_owner", + default=False, +) + DELEGATED_CHILD_ENV_MARKER = "HERMES_DELEGATED_CHILD_CONTEXT" KANBAN_ENV_KEYS: tuple[str, ...] = ( @@ -54,6 +59,30 @@ def is_delegated_child_process_context() -> bool: ) +def set_kanban_worker_owner() -> None: + """Mark the current execution context as a verified Kanban worker owner. + + Called once at the CLI boundary after verifying that the dispatcher + query marker matches ``HERMES_KANBAN_TASK``. Lifecycle code consumes + :func:`is_kanban_worker_owner` instead of re-reading ``os.environ``, + so a nested ``hermes chat`` subprocess that inherited + ``HERMES_KANBAN_*`` env vars is never treated as the parent owner. + """ + _KANBAN_WORKER_OWNER.set(True) + + +def is_kanban_worker_owner() -> bool: + """Return True only when this process has verified dispatcher ownership. + + Unlike ``os.environ.get("HERMES_KANBAN_TASK")``, this ContextVar + cannot be inherited by a child subprocess. A nested ``hermes chat`` + that inherited ``HERMES_KANBAN_*`` env vars will get ``False`` here + because the verification step in the CLI entry point won't match the + arbitrary query. + """ + return bool(_KANBAN_WORKER_OWNER.get()) + + def scrub_kanban_env(env: Mapping[str, str] | MutableMapping[str, str]) -> dict[str, str]: """Return *env* with dispatcher-only Kanban variables removed.""" cleaned = dict(env) diff --git a/agent/kanban_stop.py b/agent/kanban_stop.py index e7c2eae828a6c..da16087ae6f79 100644 --- a/agent/kanban_stop.py +++ b/agent/kanban_stop.py @@ -25,14 +25,21 @@ def kanban_stop_nudge_enabled() -> bool: """Return whether the kanban stop-guard is active for this process. - On when ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), unless - ``HERMES_KANBAN_STOP_NUDGE`` explicitly disables it. + On when the process has been verified as a dispatcher-owned kanban + worker at the CLI boundary, unless ``HERMES_KANBAN_STOP_NUDGE`` + explicitly disables it. Uses the ContextVar instead of raw env so a + nested ``hermes chat`` subprocess that inherited ``HERMES_KANBAN_*`` + env vars is not treated as the parent worker (#70809). """ env = os.environ.get("HERMES_KANBAN_STOP_NUDGE") if env is not None and env.strip().lower() in {"0", "false", "no", "off"}: return False - task = (os.environ.get("HERMES_KANBAN_TASK") or "").strip() - return bool(task) + try: + from agent.delegation_context import is_kanban_worker_owner + return is_kanban_worker_owner() + except Exception: + task = (os.environ.get("HERMES_KANBAN_TASK") or "").strip() + return bool(task) def _tool_call_name(tc: Any) -> str: diff --git a/agent/skill_utils.py b/agent/skill_utils.py index df0f933317fcd..ccab1575c90aa 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -246,13 +246,22 @@ def _detect_environment(env: str) -> bool: result = True if env == "kanban": - # Kanban is "active" either as a dispatcher-spawned worker (the - # dispatcher sets ``HERMES_KANBAN_TASK`` / ``HERMES_KANBAN_BOARD`` in the - # worker env) or as an orchestrator profile that has opted into the - # kanban toolset. Mirror the same signals the kanban tools themselves - # gate on (``tools/kanban_tools.py``) so the offer filter agrees with + # Kanban is "active" either as a dispatcher-spawned worker (verified + # at the CLI boundary and stored in a ContextVar) or as an + # orchestrator profile that has opted into the kanban toolset. + # Mirror the same signals the kanban tools themselves gate on + # (``tools/kanban_tools.py``) so the offer filter agrees with # tool availability. - if os.getenv("HERMES_KANBAN_TASK") or os.getenv("HERMES_KANBAN_BOARD"): + # Uses ContextVar instead of raw env so a nested subprocess that + # inherited ``HERMES_KANBAN_*`` env vars is not treated as a + # kanban worker (#70809). + try: + from agent.delegation_context import is_kanban_worker_owner + + _is_owner = is_kanban_worker_owner() + except Exception: + _is_owner = bool(os.getenv("HERMES_KANBAN_TASK")) + if _is_owner or os.getenv("HERMES_KANBAN_BOARD"): result = True else: try: diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 4e2d318b2e2c2..63c9e5c9c1c37 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -151,8 +151,17 @@ def finalize_turn( # We route through ``_record_task_failure(outcome="timed_out")`` # rather than ``kanban_block`` so this counts toward the dispatcher's # consecutive-failure circuit breaker (#29747 gap 2). + # + # Use the verified ContextVar instead of raw env so a nested + # ``hermes chat`` subprocess that inherited HERMES_KANBAN_* env + # vars never records a failure on the parent's task (#70809). _kanban_task = os.environ.get("HERMES_KANBAN_TASK") - if _kanban_task: + try: + from agent.delegation_context import is_kanban_worker_owner as _is_owner + _is_kanban_worker = _is_owner() + except Exception: + _is_kanban_worker = bool(_kanban_task) + if _kanban_task and _is_kanban_worker: try: from hermes_cli import kanban_db as _kb _conn = _kb.connect() diff --git a/cli.py b/cli.py index a6586e8eb0e83..b6d9e1f64422f 100644 --- a/cli.py +++ b/cli.py @@ -16031,6 +16031,15 @@ def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None: """ import os as _os + # Gate on ContextVar so a nested subprocess that inherited + # HERMES_KANBAN_* env vars never enters the goal loop (#70809). + try: + from agent.delegation_context import is_kanban_worker_owner as _goal_owner + if not _goal_owner(): + return + except Exception: + pass + task_id = (_os.environ.get("HERMES_KANBAN_TASK") or "").strip() if not task_id: return @@ -16368,7 +16377,15 @@ def _signal_handler_q(signum, frame): # first so the final debug trace isn't lost; SIGALRM deadman guards # the flush against any rare blocking-I/O case (the reporter measured # flush in <1ms; the alarm is a failsafe, not the common path). - if os.environ.get("HERMES_KANBAN_TASK"): + # Use the verified ContextVar instead of raw env so a nested + # subprocess that inherited HERMES_KANBAN_* env vars never + # triggers os._exit(0) on signal (#70809). + try: + from agent.delegation_context import is_kanban_worker_owner as _sig_owner + _is_kanban = _sig_owner() + except Exception: + _is_kanban = bool(os.environ.get("HERMES_KANBAN_TASK")) + if _is_kanban: try: import signal as _sig_mod if hasattr(_sig_mod, "SIGALRM"): @@ -16405,6 +16422,27 @@ def _signal_handler_q(signum, frame): sys.exit(1) try: query, single_query_images = _collect_query_images(query, image) + + # ── Derive Kanban worker ownership at the CLI boundary (#70809) ── + # The dispatcher spawns workers with: + # hermes chat -q "work kanban task " + # A nested ``hermes chat`` subprocess inherits HERMES_KANBAN_* env + # vars but its query won't match this marker — it is NOT the + # dispatcher-owned worker. Verify once here and store the result + # in a ContextVar so lifecycle code (heartbeat, goal loop, signal + # handler, stop-nudge) reads the verified identity instead of + # re-reading os.environ. + _raw_query = isinstance(query, str) and query.strip() + _tid = os.environ.get("HERMES_KANBAN_TASK", "").strip() + if _raw_query and _tid: + try: + from agent.delegation_context import set_kanban_worker_owner as _set_owner + + if _raw_query == f"work kanban task {_tid}": + _set_owner() + except Exception: + pass + # Kanban workers spawn with ``hermes chat -q "work kanban task "``; # the actual task description lives in the task body. Mirror the # gateway/CLI behaviour for inbound images by scanning the body for @@ -16412,36 +16450,45 @@ def _signal_handler_q(signum, frame): # worker's first turn. Without this, users who paste a screenshot # path or URL into a kanban task body never get it routed to the # model's vision input. + # Guard with the ContextVar so a nested subprocess with inherited + # env vars doesn't try to read the kanban DB (#70809). single_query_image_urls: list[str] = [] _kanban_task_id = os.environ.get("HERMES_KANBAN_TASK", "").strip() if _kanban_task_id: try: - from hermes_cli import kanban_db as _kb - from agent.image_routing import extract_image_refs as _extract_refs + from agent.delegation_context import is_kanban_worker_owner as _is_owner - _conn = _kb.connect() - try: - _task = _kb.get_task(_conn, _kanban_task_id) - finally: + _should_enrich = _is_owner() + except Exception: + _should_enrich = True + if _should_enrich: try: - _conn.close() - except Exception: - pass - _body = getattr(_task, "body", "") if _task is not None else "" - if _body: - _kb_paths, _kb_urls = _extract_refs(_body) - if _kb_paths: - # Dedupe against any --image the user already passed. - _seen = {str(p) for p in single_query_images} - for _p in _kb_paths: - if _p not in _seen: - _seen.add(_p) - single_query_images.append(Path(_p)) - if _kb_urls: - single_query_image_urls.extend(_kb_urls) - except Exception as _exc: - # Best-effort enrichment; never block worker startup on it. - logger.debug("kanban image-ref extraction failed: %s", _exc) + from hermes_cli import kanban_db as _kb + from agent.image_routing import extract_image_refs as _extract_refs + + _conn = _kb.connect() + try: + _task = _kb.get_task(_conn, _kanban_task_id) + finally: + try: + _conn.close() + except Exception: + pass + _body = getattr(_task, "body", "") if _task is not None else "" + if _body: + _kb_paths, _kb_urls = _extract_refs(_body) + if _kb_paths: + # Dedupe against any --image the user already passed. + _seen = {str(p) for p in single_query_images} + for _p in _kb_paths: + if _p not in _seen: + _seen.add(_p) + single_query_images.append(Path(_p)) + if _kb_urls: + single_query_image_urls.extend(_kb_urls) + except Exception as _exc: + # Best-effort enrichment; never block worker startup on it. + logger.debug("kanban image-ref extraction failed: %s", _exc) if quiet: # Quiet mode: suppress banner, spinner, tool previews. # Only print the final response and parseable session info. @@ -16584,7 +16631,16 @@ def _signal_handler_q(signum, frame): _exit_code = 0 if isinstance(result, dict) and result.get("failed"): _exit_code = 1 - if os.environ.get("HERMES_KANBAN_TASK") and result.get( + # Use the verified ContextVar instead of raw env so + # a nested subprocess with inherited HERMES_KANBAN_* + # env vars doesn't get the rate-limit exit code on + # someone else's task (#70809). + try: + from agent.delegation_context import is_kanban_worker_owner as _exit_owner + _exit_wk = _exit_owner() + except Exception: + _exit_wk = bool(os.environ.get("HERMES_KANBAN_TASK")) + if _exit_wk and result.get( "failure_reason" ) in ("rate_limit", "billing"): try: diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 7d585176efd41..77ade98a40035 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -120,7 +120,15 @@ def _is_kanban_worker_env_gate(item: dict) -> bool: """Return True when Kanban is unavailable only because this is not a worker process.""" if item.get("name") != "kanban": return False - if os.environ.get("HERMES_KANBAN_TASK"): + # Use ContextVar so a nested subprocess with inherited env vars + # is not treated as a kanban worker (#70809). + try: + from agent.delegation_context import is_kanban_worker_owner + + _is_wk = is_kanban_worker_owner() + except Exception: + _is_wk = bool(os.environ.get("HERMES_KANBAN_TASK")) + if _is_wk: return False tools = item.get("tools") or [] @@ -129,7 +137,15 @@ def _is_kanban_worker_env_gate(item: dict) -> bool: def _doctor_tool_availability_detail(toolset: str) -> str: """Optional explanatory suffix for toolsets whose doctor status needs context.""" - if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"): + # Use ContextVar so a nested subprocess with inherited env vars + # is not treated as a kanban worker (#70809). + try: + from agent.delegation_context import is_kanban_worker_owner + + _is_wk = is_kanban_worker_owner() + except Exception: + _is_wk = bool(os.environ.get("HERMES_KANBAN_TASK")) + if toolset == "kanban" and not _is_wk: return "(runtime-gated; loaded only for dispatcher-spawned workers)" return "" diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 6bcc7651b43b8..ae5c8fbfe14d1 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -2105,6 +2105,13 @@ def _cmd_attach_rm(args: argparse.Namespace) -> int: def _worker_run_id_for(task_id: str) -> Optional[int]: + # Use ContextVar to verify ownership (#70809). + try: + from agent.delegation_context import is_kanban_worker_owner + if not is_kanban_worker_owner(): + return None + except Exception: + pass if os.environ.get("HERMES_KANBAN_TASK") != task_id: return None raw = os.environ.get("HERMES_KANBAN_RUN_ID") diff --git a/model_tools.py b/model_tools.py index 32394a69eec64..5176852e6ff18 100644 --- a/model_tools.py +++ b/model_tools.py @@ -48,6 +48,21 @@ def _is_delegated_child_context() -> bool: return False +def _is_kanban_worker_owner() -> bool: + """Return True when this process has verified dispatcher ownership. + + Uses the ContextVar set at the CLI boundary rather than raw env so a + nested ``hermes chat`` subprocess that inherited ``HERMES_KANBAN_*`` + env vars is never treated as the parent worker (#70809). + """ + try: + from agent.delegation_context import is_kanban_worker_owner + + return is_kanban_worker_owner() + except Exception: + return bool(os.environ.get("HERMES_KANBAN_TASK")) + + # ============================================================================= # Async Bridging (single source of truth -- used by registry.dispatch too) # ============================================================================= @@ -330,7 +345,7 @@ def get_tool_definitions( frozenset(disabled_toolsets) if disabled_toolsets else None, registry._generation, cfg_fp, - bool(os.environ.get("HERMES_KANBAN_TASK")), + bool(_is_kanban_worker_owner()), bool(skip_tool_search_assembly), _is_delegated_child_context(), ) @@ -377,7 +392,7 @@ def _compute_tool_definitions( if enabled_toolsets is not None: effective_enabled_toolsets = list(enabled_toolsets) if ( - os.environ.get("HERMES_KANBAN_TASK") + _is_kanban_worker_owner() and not _is_delegated_child_context() and "kanban" not in effective_enabled_toolsets ): diff --git a/run_agent.py b/run_agent.py index e4cd9f54e8ce9..4f6dd54426289 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3432,7 +3432,15 @@ def _touch_activity(self, desc: str) -> None: """ self._last_activity_ts = time.time() self._last_activity_desc = desc - if os.environ.get("HERMES_KANBAN_TASK"): + # Use the verified ContextVar instead of raw env so a nested + # ``hermes chat`` subprocess that inherited HERMES_KANBAN_* env + # vars never heartbeats as the parent owner (#70809). + try: + from agent.delegation_context import is_kanban_worker_owner as _is_owner + _should_heartbeat = _is_owner() + except Exception: + _should_heartbeat = bool(os.environ.get("HERMES_KANBAN_TASK")) + if _should_heartbeat: try: from tools.kanban_tools import heartbeat_current_worker_from_env heartbeat_current_worker_from_env() diff --git a/tests/tools/test_delegate_kanban_isolation.py b/tests/tools/test_delegate_kanban_isolation.py index 10e72efd3b45c..49be216e52e6f 100644 --- a/tests/tools/test_delegate_kanban_isolation.py +++ b/tests/tools/test_delegate_kanban_isolation.py @@ -5,6 +5,7 @@ import os import shlex import sys +import textwrap from pathlib import Path import pytest @@ -622,3 +623,125 @@ def close(self): assert task.status == "running" assert run.status == "running" assert workspace.is_dir() + + +# ── ContextVar-based kanban ownership (#70809) ─────────────────────────────── + + +def test_kanban_worker_owner_not_set_by_default(): + """``is_kanban_worker_owner()`` returns False without a prior call to + ``set_kanban_worker_owner()``, even if HERMES_KANBAN_TASK is in the env.""" + from agent.delegation_context import is_kanban_worker_owner + + os.environ["HERMES_KANBAN_TASK"] = "t_test_owned" + try: + assert is_kanban_worker_owner() is False + finally: + os.environ.pop("HERMES_KANBAN_TASK", None) + + +def test_set_kanban_worker_owner_makes_is_owner_true(): + """After calling ``set_kanban_worker_owner()``, ``is_kanban_worker_owner()`` + returns True, regardless of whether the env var is set.""" + from agent.delegation_context import is_kanban_worker_owner, set_kanban_worker_owner + + set_kanban_worker_owner() + assert is_kanban_worker_owner() is True + + +def test_kanban_worker_owner_does_not_propagate_to_subprocess(): + """A subprocess spawned after ``set_kanban_worker_owner()`` must NOT + inherit the ContextVar — it starts with a clean context.""" + import subprocess + + from agent.delegation_context import set_kanban_worker_owner + + set_kanban_worker_owner() + + repo_root = Path(__file__).resolve().parents[2] + code = ( + "from agent.delegation_context import is_kanban_worker_owner; " + "print(is_kanban_worker_owner(), flush=True)" + ) + result = subprocess.run( + [ + sys.executable, + "-c", + code, + ], + capture_output=True, + text=True, + timeout=10, + env={ + **{ + k: v + for k, v in os.environ.items() + if k not in ("HERMES_KANBAN_TASK", "HERMES_KANBAN_RUN_ID") + }, + "PYTHONPATH": str(repo_root), + }, + ) + assert result.returncode == 0, f"stderr: {result.stderr}" + output = result.stdout.strip() + assert output == "False", ( + f"subprocess should see is_kanban_worker_owner=False, got {output!r}" + ) + + +def test_nested_cli_with_inherited_env_not_owned(): + """Simulate a nested ``hermes chat`` subprocess that inherited + ``HERMES_KANBAN_TASK`` from the parent but whose query does NOT match the + dispatcher marker — the ``set_kanban_worker_owner`` guard at the CLI entry + point must NOT fire.""" + + # Simulate the exact gate logic from cli.py lines 16418-16427: + # _raw_query = isinstance(query, str) and query.strip() + # _tid = os.environ.get("HERMES_KANBAN_TASK", "").strip() + # if _raw_query and _tid: + # if _raw_query == f"work kanban task {_tid}": + # set_kanban_worker_owner() + from agent.delegation_context import is_kanban_worker_owner, set_kanban_worker_owner + + # ── Case 1: matching query → owner ── + os.environ["HERMES_KANBAN_TASK"] = "t_42" + _raw_query = "work kanban task t_42" + _tid = "t_42" + if _raw_query == f"work kanban task {_tid}": + set_kanban_worker_owner() + assert is_kanban_worker_owner() is True, "matching query must set ownership" + os.environ.pop("HERMES_KANBAN_TASK", None) + + # Reset ContextVar for next case (fresh token via new thread-like context) + # We can't truly reset without a ContextVar.reset(token), so we demonstrate + # via subprocess isolation. + import subprocess + + repo_root = Path(__file__).resolve().parents[2] + + # Simulate a nested subprocess that inherited the env var with a different query + code = textwrap.dedent( + """ + import os + from agent.delegation_context import is_kanban_worker_owner, set_kanban_worker_owner + + # Inherited env, but query is arbitrary — NOT the dispatcher marker + os.environ.setdefault("HERMES_KANBAN_TASK", "t_parent") + _raw_query = "tell me a joke" + _tid = os.environ.get("HERMES_KANBAN_TASK", "").strip() + if _raw_query and _tid: + if _raw_query == f"work kanban task {_tid}": + set_kanban_worker_owner() + print(f"owned={is_kanban_worker_owner()}", flush=True) + """ + ) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=10, + env={**os.environ, "PYTHONPATH": str(repo_root)}, + ) + assert result.returncode == 0, f"stderr: {result.stderr}" + assert "owned=False" in result.stdout, ( + f"nested process must NOT inherit ownership, got: {result.stdout}" + ) diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 46991b4a477b7..3510eae9839e4 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -92,7 +92,8 @@ def _reject_delegated_child_mutation(tool_name: str) -> Optional[str]: def _check_kanban_mode() -> bool: """Task-lifecycle tools are available when: - 1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR + 1. This process has been verified as a dispatcher-owned kanban + worker at the CLI boundary (ContextVar, not raw env), OR 2. The current profile has ``kanban`` in its toolsets config (orchestrator profiles like techlead that route work via Kanban). @@ -100,11 +101,21 @@ def _check_kanban_mode() -> bool: kanban tools. Workers spawned by the kanban dispatcher (gateway- embedded by default) and orchestrator profiles with the kanban toolset enabled see the Kanban lifecycle tool surface. + + Uses a ContextVar instead of raw env so a nested ``hermes chat`` + subprocess that inherited ``HERMES_KANBAN_*`` env vars is never + treated as the parent worker (#70809). """ if _is_delegated_child_context(): return False - if os.environ.get("HERMES_KANBAN_TASK"): - return True + try: + from agent.delegation_context import is_kanban_worker_owner + + if is_kanban_worker_owner(): + return True + except Exception: + if os.environ.get("HERMES_KANBAN_TASK"): + return True return _profile_has_kanban_toolset() @@ -116,11 +127,22 @@ def _check_kanban_orchestrator_mode() -> bool: lifecycle tools (complete/block/heartbeat), not enumerate or unblock board state. Profiles that explicitly opt into the kanban toolset and are NOT scoped to a single task are the orchestrator surface. + + Uses the ContextVar instead of raw env, so a nested ``hermes chat`` + subprocess that inherited ``HERMES_KANBAN_*`` env vars is not + treated as a scoped worker; it falls through to the profile-level + toolset check (#70809). """ if _is_delegated_child_context(): return False - if os.environ.get("HERMES_KANBAN_TASK"): - return False + try: + from agent.delegation_context import is_kanban_worker_owner + + if is_kanban_worker_owner(): + return False + except Exception: + if os.environ.get("HERMES_KANBAN_TASK"): + return False return _profile_has_kanban_toolset() @@ -129,17 +151,38 @@ def _check_kanban_orchestrator_mode() -> bool: # --------------------------------------------------------------------------- def _default_task_id(arg: Optional[str]) -> Optional[str]: - """Resolve ``task_id`` arg or fall back to the env var the dispatcher set.""" + """Resolve ``task_id`` arg or fall back to the env var the dispatcher set. + + Uses the ContextVar to verify ownership so a nested ``hermes chat`` + subprocess with inherited env vars does not silently bind to the + parent's task (#70809). + """ if arg: return arg if _is_delegated_child_context(): return None + # Only return the env var if this process is the verified owner. + try: + from agent.delegation_context import is_kanban_worker_owner + is_owner = is_kanban_worker_owner() + except Exception: + is_owner = bool(os.environ.get("HERMES_KANBAN_TASK")) + if not is_owner: + return None env_tid = os.environ.get("HERMES_KANBAN_TASK") return env_tid or None def _worker_run_id(task_id: str) -> Optional[int]: """Return this worker's dispatcher run id when it is scoped to task_id.""" + # Use ContextVar to verify ownership: a nested subprocess with + # inherited env vars must not act as the parent worker (#70809). + try: + from agent.delegation_context import is_kanban_worker_owner + if not is_kanban_worker_owner(): + return None + except Exception: + pass if os.environ.get("HERMES_KANBAN_TASK") != task_id: return None raw = os.environ.get("HERMES_KANBAN_RUN_ID") @@ -155,6 +198,13 @@ def _stamp_worker_session_metadata( task_id: str, metadata: Optional[dict] ) -> Optional[dict]: """Add trusted worker session id metadata for this worker's own task.""" + # Use ContextVar to verify ownership (#70809). + try: + from agent.delegation_context import is_kanban_worker_owner + if not is_kanban_worker_owner(): + return metadata + except Exception: + pass if os.environ.get("HERMES_KANBAN_TASK") != task_id: return metadata session_id = os.environ.get("HERMES_SESSION_ID") @@ -183,7 +233,18 @@ def _enforce_worker_task_ownership(tid: str) -> Optional[str]: Returns ``None`` when the call is allowed, or a tool-error string when it must be rejected. Callers should ``return`` the error verbatim. + + Uses the ContextVar so a nested subprocess with inherited env vars + is not subject to this restriction — it cannot reach this code since + the lifecycle tools are gated by ``_check_kanban_mode`` (#70809). """ + # Only enforce ownership when this process is the verified owner. + try: + from agent.delegation_context import is_kanban_worker_owner + if not is_kanban_worker_owner(): + return None + except Exception: + pass env_tid = os.environ.get("HERMES_KANBAN_TASK") if not env_tid: # Orchestrator or CLI context — no task-scope restriction. diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 9c1d41eaf0fd7..86d12f7498000 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -1974,9 +1974,19 @@ def _check_send_message(): summary), which is the canonical pattern for any worker that needs to reply with more than the ~200-char first-line truncation the kanban notifier applies. + + Uses the ContextVar instead of raw env so a nested ``hermes chat`` + subprocess that inherited ``HERMES_KANBAN_*`` env vars does not + bypass the gateway-running check (#70809). """ - if os.environ.get("HERMES_KANBAN_TASK"): - return True + try: + from agent.delegation_context import is_kanban_worker_owner + + if is_kanban_worker_owner(): + return True + except Exception: + if os.environ.get("HERMES_KANBAN_TASK"): + return True from gateway.session_context import get_session_env platform = get_session_env("HERMES_SESSION_PLATFORM", "") if platform and platform != "local":