From aea87a4d9ff9fe70db15486eebad82257b3ba1f7 Mon Sep 17 00:00:00 2001 From: Benjamin PERRY Date: Mon, 24 Aug 2026 09:05:08 +0000 Subject: [PATCH 1/2] fix(kanban): authenticate native worker launches --- agent/delegation_context.py | 55 ++++--- hermes_cli/_parser.py | 6 + hermes_cli/kanban_db.py | 5 + hermes_cli/main.py | 44 +++--- .../test_kanban_child_chat_startup.py | 136 ++++++++++++++++-- .../test_kanban_child_chat_env_isolation.py | 8 ++ tools/environments/local.py | 1 + 7 files changed, 203 insertions(+), 52 deletions(-) diff --git a/agent/delegation_context.py b/agent/delegation_context.py index d9b84318ec7f..feb484ec4c1c 100644 --- a/agent/delegation_context.py +++ b/agent/delegation_context.py @@ -41,8 +41,13 @@ DELEGATED_CHILD_ENV_MARKER = "HERMES_DELEGATED_CHILD_CONTEXT" +# One-shot machine proof emitted only by the native Kanban spawner. Its value +# is the exact task id and must match the hidden CLI launch argument too. +KANBAN_WORKER_LAUNCH_MARKER = "HERMES_KANBAN_WORKER_LAUNCH" + KANBAN_ENV_KEYS: tuple[str, ...] = ( "HERMES_KANBAN_TASK", + KANBAN_WORKER_LAUNCH_MARKER, "HERMES_KANBAN_RUN_ID", "HERMES_KANBAN_WORKSPACE", "HERMES_KANBAN_WORKSPACES_ROOT", @@ -57,15 +62,13 @@ # makes ``kanban_complete`` default to the parent card. KANBAN_LIFECYCLE_OWNERSHIP_KEYS: tuple[str, ...] = ( "HERMES_KANBAN_TASK", + KANBAN_WORKER_LAUNCH_MARKER, "HERMES_KANBAN_RUN_ID", "HERMES_KANBAN_WORKSPACE", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_CLAIM_LOCK", ) -_BOARD_WORKER_QUERY_PREFIX = "work kanban task " - - @contextmanager def delegated_child_context(session_id: str | None = None) -> Iterator[None]: """Mark child execution and isolate its task-local session identity. @@ -178,51 +181,59 @@ def scrub_kanban_lifecycle_ownership( def is_explicit_board_worker_launch( *, - query: str | None, source: str | None, task_id: str | None, + launch_marker: str | None, + launch_arg: str | None, ) -> bool: - """True only for the dispatcher worker argv/env contract. + """True only for the native dispatcher's task-bound machine contract. - ``_default_spawn`` launches ``hermes chat -q "work kanban task "`` - with ``HERMES_SESSION_SOURCE=kanban``. Any other child chat — including - ``hermes chat --source tool`` used by Browser Use benchmarks — is not - the board worker, even if it inherited the parent's env. + Query text is deliberately irrelevant: a human phrase cannot authenticate + a worker. Source, durable task id, one-shot env marker, and hidden argv + proof must all agree exactly. """ - tid = (task_id or "").strip() - if not tid: + tid = task_id or "" + if not tid or tid != tid.strip(): return False - if (source or "").strip() != "kanban": + if (source or "") != "kanban": return False - return (query or "").strip() == f"{_BOARD_WORKER_QUERY_PREFIX}{tid}" + return launch_marker == tid and launch_arg == tid def drop_inherited_kanban_lifecycle_if_not_board_worker( *, - query: str | None = None, source: str | None = None, + launch_arg: str | None = None, environ: MutableMapping[str, str] | None = None, ) -> bool: """Drop inherited lifecycle ownership unless this process is the worker. - Returns True when vars were removed. Mutates *environ* (default - ``os.environ``) so a child ``hermes chat`` that slipped past spawn-time - scrubbing still cannot ``kanban_complete`` the parent card. + Returns True when lifecycle authority was rejected and removed. A valid + worker keeps its runtime ownership, consumes only the one-shot launch + marker, and returns False. Mutates *environ* (default ``os.environ``) so a + child ``hermes chat`` that slipped past spawn-time scrubbing still cannot + ``kanban_complete`` the parent card. """ import os env = os.environ if environ is None else environ - task = (env.get("HERMES_KANBAN_TASK") or "").strip() - if not task: - return False + task = env.get("HERMES_KANBAN_TASK") or "" resolved_source = source if source is not None else env.get("HERMES_SESSION_SOURCE") if is_explicit_board_worker_launch( - query=query, source=resolved_source, task_id=task + source=resolved_source, + task_id=task, + launch_marker=env.get(KANBAN_WORKER_LAUNCH_MARKER), + launch_arg=launch_arg, ): + # Consume the one-shot env proof immediately. Runtime ownership keys + # remain for the real worker, but no raw-env child can inherit enough + # material to mint itself as another worker. + env.pop(KANBAN_WORKER_LAUNCH_MARKER, None) return False + removed = any(key in env for key in KANBAN_LIFECYCLE_OWNERSHIP_KEYS) for key in KANBAN_LIFECYCLE_OWNERSHIP_KEYS: env.pop(key, None) - return True + return removed def delegated_child_subprocess_env( diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 13bcaa4703ef..422c65b042f4 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -314,6 +314,12 @@ def build_top_level_parser(): "verbatim. Mutually exclusive with -q." ), ) + chat_parser.add_argument( + "--kanban-worker-launch", + metavar="TASK_ID", + default=None, + help=argparse.SUPPRESS, + ) chat_parser.add_argument( "--image", help="Optional local image path to attach to a single query" ) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 79398b7d6881..0dc023fe982e 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -10762,6 +10762,10 @@ def _default_spawn( if task.tenant: env["HERMES_TENANT"] = task.tenant env["HERMES_KANBAN_TASK"] = task.id + # One-shot machine proof paired with the hidden CLI argument below. Only + # this native dispatcher boundary mints it; cmd_chat consumes it at startup. + from agent.delegation_context import KANBAN_WORKER_LAUNCH_MARKER + env[KANBAN_WORKER_LAUNCH_MARKER] = task.id env["HERMES_KANBAN_WORKSPACE"] = workspace # Tag the worker's session so it lands in state.db as `kanban`, not as an # untitled `cli` row. A worker is a dispatcher-owned run whose transcript is @@ -10875,6 +10879,7 @@ def _default_spawn( cmd.extend(["--toolsets", ",".join(worker_toolsets)]) cmd.extend([ "chat", + "--kanban-worker-launch", task.id, "-q", prompt, ]) if task.goal_mode: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 18f267814db4..eccff5173331 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2867,22 +2867,36 @@ def _pin_kanban_board_env() -> None: def _drop_inherited_kanban_lifecycle(args) -> None: - """Child chats must not inherit dispatcher run ownership. + """Validate and consume the native worker launch proof. A Kanban worker that shells out to ``hermes chat --source tool`` (Browser Use benchmarks, local-model evals) used to keep ``HERMES_KANBAN_TASK`` - and could ``kanban_complete`` the parent card. The dispatcher worker - itself is identified by source ``kanban`` plus ``work kanban task ``. + and could ``kanban_complete`` the parent card. A real worker needs matching + source, task-bound env marker, and hidden CLI launch argument; query text + is never authority. """ try: from agent.delegation_context import ( drop_inherited_kanban_lifecycle_if_not_board_worker, ) - except Exception: + except ImportError: + # Security boundary: validator unavailability must fail closed. Keep + # BOARD / DB routing pins so ordinary ``hermes kanban`` shell-outs stay + # on the selected board; remove only lifecycle ownership and launch + # proof. Other import-time exceptions are deliberately not swallowed. + for key in ( + "HERMES_KANBAN_TASK", + "HERMES_KANBAN_RUN_ID", + "HERMES_KANBAN_WORKSPACE", + "HERMES_KANBAN_WORKSPACES_ROOT", + "HERMES_KANBAN_CLAIM_LOCK", + "HERMES_KANBAN_WORKER_LAUNCH", + ): + os.environ.pop(key, None) return drop_inherited_kanban_lifecycle_if_not_board_worker( - query=getattr(args, "query", None), source=os.environ.get("HERMES_SESSION_SOURCE"), + launch_arg=getattr(args, "kanban_worker_launch", None), ) @@ -2951,6 +2965,13 @@ def _resolve_use_tui(args) -> bool: def cmd_chat(args): """Run interactive chat CLI.""" + # Validate the one-shot Kanban worker launch proof before any startup work + # can spawn a child that inherits process env. An explicit --source wins + # over an inherited source exactly as it does for session tagging below. + if getattr(args, "source", None): + os.environ["HERMES_SESSION_SOURCE"] = args.source + _drop_inherited_kanban_lifecycle(args) + use_tui = _resolve_use_tui(args) _apply_safe_mode(args) @@ -3200,16 +3221,6 @@ def _skills_sync_bg() -> None: if getattr(args, "ignore_rules", False): os.environ["HERMES_IGNORE_RULES"] = "1" - # --source: tag session source for filtering (e.g. 'tool' for third-party integrations) - if getattr(args, "source", None): - os.environ["HERMES_SESSION_SOURCE"] = args.source - - # Defer the drop when the query is still on disk: a --query-file board - # worker would otherwise look like an interactive child and lose ownership - # before the prompt is loaded. - if getattr(args, "query", None) or not getattr(args, "query_file", None): - _drop_inherited_kanban_lifecycle(args) - _pin_kanban_board_env() _confirm_startup_expensive_model_override(args) @@ -3260,9 +3271,6 @@ def _skills_sync_bg() -> None: print(f"Error: --query-file {_qfile} is empty", file=sys.stderr) sys.exit(2) - # Query may have arrived via --query-file after the first ownership check. - _drop_inherited_kanban_lifecycle(args) - # Build kwargs from args kwargs = { "model": args.model, diff --git a/tests/hermes_cli/test_kanban_child_chat_startup.py b/tests/hermes_cli/test_kanban_child_chat_startup.py index d340de397bdb..a90f6c0ca0f2 100644 --- a/tests/hermes_cli/test_kanban_child_chat_startup.py +++ b/tests/hermes_cli/test_kanban_child_chat_startup.py @@ -4,8 +4,7 @@ ``tests/tools/test_kanban_child_chat_env_isolation.py``: even if a child process is launched with a raw ``os.environ`` copy (outside the terminal sanitize path), ``cmd_chat`` must refuse to become the board worker unless -it was explicitly launched as one (source ``kanban`` + -``work kanban task ``). +it carries the task-bound machine marker emitted by the native dispatcher. """ from __future__ import annotations @@ -23,6 +22,7 @@ "HERMES_KANBAN_WORKSPACE", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_CLAIM_LOCK", + "HERMES_KANBAN_WORKER_LAUNCH", ) @@ -52,6 +52,7 @@ def _chat_args(**overrides): "safe_mode": False, "compact": False, "source": None, + "kanban_worker_launch": None, "yolo": False, "accept_hooks": False, "in_dir": None, @@ -98,12 +99,16 @@ def fake_cli_main(**kwargs): return captured -def _inherit_worker_identity(monkeypatch, tmp_path, task_id="t_parent"): +def _inherit_worker_identity( + monkeypatch, tmp_path, task_id="t_parent", launch_marker=None, +): monkeypatch.setenv("HERMES_KANBAN_TASK", task_id) monkeypatch.setenv("HERMES_KANBAN_RUN_ID", "18") monkeypatch.setenv("HERMES_KANBAN_WORKSPACE", str(tmp_path / "ws")) monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(tmp_path / "workspaces")) monkeypatch.setenv("HERMES_KANBAN_CLAIM_LOCK", "lock-parent") + if launch_marker is not None: + monkeypatch.setenv("HERMES_KANBAN_WORKER_LAUNCH", launch_marker) monkeypatch.setenv("HERMES_KANBAN_BOARD", "default") monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "kanban.db")) monkeypatch.setenv("HERMES_SESSION_SOURCE", "kanban") @@ -112,10 +117,11 @@ def _inherit_worker_identity(monkeypatch, tmp_path, task_id="t_parent"): def test_source_tool_child_chat_drops_inherited_ownership( main_mod, fake_cli, monkeypatch, tmp_path ): - _inherit_worker_identity(monkeypatch, tmp_path) + _inherit_worker_identity(monkeypatch, tmp_path, launch_marker="t_parent") main_mod.cmd_chat( _chat_args( source="tool", + kanban_worker_launch="t_parent", query="Benchmark Browser Use on books.toscrape.com", quiet=True, ) @@ -129,6 +135,36 @@ def test_source_tool_child_chat_drops_inherited_ownership( assert env["HERMES_KANBAN_DB"] == str(tmp_path / "kanban.db") +def test_validator_import_failure_fails_closed( + main_mod, monkeypatch, tmp_path +): + import builtins + + _inherit_worker_identity( + monkeypatch, + tmp_path, + task_id="t_board_worker", + launch_marker="t_board_worker", + ) + real_import = builtins.__import__ + + def _fail_validator_import(name, *args, **kwargs): + if name == "agent.delegation_context": + raise ImportError("validator unavailable") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _fail_validator_import) + main_mod._drop_inherited_kanban_lifecycle(_chat_args( + source="kanban", + kanban_worker_launch="t_board_worker", + )) + + for key in _OWNERSHIP_KEYS: + assert os.environ.get(key) is None, key + assert os.environ["HERMES_KANBAN_BOARD"] == "default" + assert os.environ["HERMES_KANBAN_DB"] == str(tmp_path / "kanban.db") + + def test_inherited_kanban_source_without_worker_prompt_drops_ownership( main_mod, fake_cli, monkeypatch, tmp_path ): @@ -142,28 +178,104 @@ def test_inherited_kanban_source_without_worker_prompt_drops_ownership( assert env["HERMES_SESSION_SOURCE"] == "kanban" -def test_explicit_board_worker_launch_keeps_ownership( +def test_explicit_board_worker_launch_keeps_ownership_for_any_query_wording( main_mod, fake_cli, monkeypatch, tmp_path ): - _inherit_worker_identity(monkeypatch, tmp_path, task_id="t_board_worker") + _inherit_worker_identity( + monkeypatch, + tmp_path, + task_id="t_board_worker", + launch_marker="t_board_worker", + ) main_mod.cmd_chat( - _chat_args(source=None, query="work kanban task t_board_worker") + _chat_args( + source=None, + query="execute the assigned board card now", + kanban_worker_launch="t_board_worker", + ) ) env = fake_cli["env"] assert env["HERMES_KANBAN_TASK"] == "t_board_worker" assert env["HERMES_KANBAN_RUN_ID"] == "18" assert env["HERMES_KANBAN_CLAIM_LOCK"] == "lock-parent" + assert env["HERMES_KANBAN_WORKER_LAUNCH"] is None assert env["HERMES_SESSION_SOURCE"] == "kanban" -def test_source_kanban_with_mismatched_prompt_drops_ownership( - main_mod, fake_cli, monkeypatch, tmp_path +@pytest.mark.parametrize( + "source, launch_marker, launch_arg", + [ + ("kanban", "t_board_worker", None), + ("kanban", None, "t_board_worker"), + ("kanban", "", "t_board_worker"), + ("kanban", "t_other", "t_board_worker"), + ("kanban", "t_board_worker", ""), + ("kanban", "t_board_worker", "t_other"), + ("tool", "t_board_worker", "t_board_worker"), + (" kanban ", "t_board_worker", "t_board_worker"), + ], +) +def test_source_task_marker_and_flag_must_all_match( + main_mod, fake_cli, monkeypatch, tmp_path, + source, launch_marker, launch_arg, ): - _inherit_worker_identity(monkeypatch, tmp_path, task_id="t_board_worker") + _inherit_worker_identity( + monkeypatch, + tmp_path, + task_id="t_board_worker", + launch_marker=launch_marker, + ) main_mod.cmd_chat( - _chat_args(source="kanban", query="work kanban task t_other") + # Human text that exactly resembles the legacy worker prompt is not + # machine authority. + _chat_args( + source=source, + query="work kanban task t_board_worker", + kanban_worker_launch=launch_arg, + ) ) env = fake_cli["env"] - assert env.get("HERMES_KANBAN_TASK") is None + for key in _OWNERSHIP_KEYS: + assert env.get(key) is None, key + + +def test_consumed_marker_cannot_authorize_a_nested_chat( + main_mod, fake_cli, monkeypatch, tmp_path +): + _inherit_worker_identity( + monkeypatch, + tmp_path, + task_id="t_board_worker", + launch_marker="t_board_worker", + ) + main_mod.cmd_chat(_chat_args( + source="kanban", + query="arbitrary worker wording", + kanban_worker_launch="t_board_worker", + )) + assert fake_cli["env"]["HERMES_KANBAN_TASK"] == "t_board_worker" + assert os.environ.get("HERMES_KANBAN_WORKER_LAUNCH") is None + + # A raw-env child can inherit the runtime task keys, but the consumed env + # marker and one-shot internal argv proof are both gone. + main_mod.cmd_chat(_chat_args( + source="kanban", + query="work kanban task t_board_worker", + kanban_worker_launch=None, + )) + for key in _OWNERSHIP_KEYS: + assert fake_cli["env"].get(key) is None, key + + +def test_internal_worker_launch_flag_is_accepted_but_hidden_from_help(): + from hermes_cli._parser import build_top_level_parser + + parser, _subparsers, chat_parser = build_top_level_parser() + args = parser.parse_args([ + "chat", "--kanban-worker-launch", "t_worker", "-q", "anything", + ]) + + assert args.kanban_worker_launch == "t_worker" + assert "--kanban-worker-launch" not in chat_parser.format_help() diff --git a/tests/tools/test_kanban_child_chat_env_isolation.py b/tests/tools/test_kanban_child_chat_env_isolation.py index 1e3128ff4679..2e758055af12 100644 --- a/tests/tools/test_kanban_child_chat_env_isolation.py +++ b/tests/tools/test_kanban_child_chat_env_isolation.py @@ -29,6 +29,7 @@ "HERMES_KANBAN_WORKSPACE", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_CLAIM_LOCK", + "HERMES_KANBAN_WORKER_LAUNCH", ) _SAFE_SAMPLE = { @@ -59,6 +60,7 @@ def _worker_env(tmp_path: Path) -> dict[str, str]: "HERMES_KANBAN_WORKSPACE": str(tmp_path / "parent-workspace"), "HERMES_KANBAN_WORKSPACES_ROOT": str(tmp_path / "workspaces"), "HERMES_KANBAN_CLAIM_LOCK": "lock-parent", + "HERMES_KANBAN_WORKER_LAUNCH": "t_parent", "HERMES_KANBAN_BOARD": "default", "HERMES_KANBAN_DB": str(tmp_path / ".hermes" / "kanban.db"), "HERMES_SESSION_SOURCE": "kanban", @@ -167,6 +169,7 @@ class _Proc: pid = 4242 def _fake_popen(cmd, **kwargs): + captured["cmd"] = cmd captured["env"] = kwargs["env"] return _Proc() @@ -201,7 +204,12 @@ def _fake_popen(cmd, **kwargs): assert env["HERMES_KANBAN_RUN_ID"] == "7" assert env["HERMES_KANBAN_WORKSPACE"] == str(workspace) assert env["HERMES_KANBAN_CLAIM_LOCK"] == "lock-worker" + assert env["HERMES_KANBAN_WORKER_LAUNCH"] == "t_board_worker" assert env["HERMES_SESSION_SOURCE"] == "kanban" + assert captured["cmd"][-5:] == [ + "chat", "--kanban-worker-launch", "t_board_worker", + "-q", "work kanban task t_board_worker", + ] def test_terminal_child_cannot_complete_parent_and_parent_stays_running( diff --git a/tools/environments/local.py b/tools/environments/local.py index e114d30d6789..ac55c30f06d2 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -565,6 +565,7 @@ def _scrub_kanban_lifecycle_ownership(env: dict[str, str]) -> dict[str, str]: "HERMES_KANBAN_WORKSPACE", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_CLAIM_LOCK", + "HERMES_KANBAN_WORKER_LAUNCH", ): env.pop(key, None) return env From 507539d6b0ee36e9e1cfca5a1c9ed9dfc0fbe36e Mon Sep 17 00:00:00 2001 From: Benjamin PERRY Date: Mon, 24 Aug 2026 12:18:14 +0000 Subject: [PATCH 2/2] fix(kanban): mint one-shot worker launch nonces --- agent/delegation_context.py | 17 ++++-- hermes_cli/kanban_db.py | 12 ++-- hermes_cli/main.py | 4 +- tests/conftest.py | 3 +- .../test_kanban_child_chat_startup.py | 38 +++++++------ .../test_kanban_child_chat_env_isolation.py | 55 +++++++++++++------ 6 files changed, 82 insertions(+), 47 deletions(-) diff --git a/agent/delegation_context.py b/agent/delegation_context.py index feb484ec4c1c..a282f43ee625 100644 --- a/agent/delegation_context.py +++ b/agent/delegation_context.py @@ -41,8 +41,9 @@ DELEGATED_CHILD_ENV_MARKER = "HERMES_DELEGATED_CHILD_CONTEXT" -# One-shot machine proof emitted only by the native Kanban spawner. Its value -# is the exact task id and must match the hidden CLI launch argument too. +# One-shot native launch proof emitted by the Kanban spawner. It prevents +# accidental/passive lifecycle inheritance; it is not authentication against a +# malicious same-user process that can inspect another process's launch state. KANBAN_WORKER_LAUNCH_MARKER = "HERMES_KANBAN_WORKER_LAUNCH" KANBAN_ENV_KEYS: tuple[str, ...] = ( @@ -186,18 +187,22 @@ def is_explicit_board_worker_launch( launch_marker: str | None, launch_arg: str | None, ) -> bool: - """True only for the native dispatcher's task-bound machine contract. + """True only for the native dispatcher's one-shot launch contract. Query text is deliberately irrelevant: a human phrase cannot authenticate - a worker. Source, durable task id, one-shot env marker, and hidden argv - proof must all agree exactly. + a worker. Source and task id must be present, while the fresh env nonce and + hidden argv nonce must agree exactly. The public task id is never proof. """ tid = task_id or "" if not tid or tid != tid.strip(): return False if (source or "") != "kanban": return False - return launch_marker == tid and launch_arg == tid + marker = launch_marker or "" + arg = launch_arg or "" + if not marker or marker != marker.strip() or marker == tid: + return False + return marker == arg def drop_inherited_kanban_lifecycle_if_not_board_worker( diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 0dc023fe982e..517bc9c54b0c 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -10724,6 +10724,7 @@ def _default_spawn( vars all resolve to the same board the dispatcher claimed the task from. Workers cannot accidentally see other boards. """ + import secrets import subprocess if not task.assignee: raise ValueError(f"task {task.id} has no assignee") @@ -10762,10 +10763,13 @@ def _default_spawn( if task.tenant: env["HERMES_TENANT"] = task.tenant env["HERMES_KANBAN_TASK"] = task.id - # One-shot machine proof paired with the hidden CLI argument below. Only - # this native dispatcher boundary mints it; cmd_chat consumes it at startup. + # One-shot native launch proof paired with the hidden CLI argument below. + # A fresh unpredictable value prevents accidental/passive inheritance from + # being reconstructed from the public task id. This is not authentication + # against a malicious same-user process that can inspect launch state. from agent.delegation_context import KANBAN_WORKER_LAUNCH_MARKER - env[KANBAN_WORKER_LAUNCH_MARKER] = task.id + worker_launch_proof = secrets.token_urlsafe(32) + env[KANBAN_WORKER_LAUNCH_MARKER] = worker_launch_proof env["HERMES_KANBAN_WORKSPACE"] = workspace # Tag the worker's session so it lands in state.db as `kanban`, not as an # untitled `cli` row. A worker is a dispatcher-owned run whose transcript is @@ -10879,7 +10883,7 @@ def _default_spawn( cmd.extend(["--toolsets", ",".join(worker_toolsets)]) cmd.extend([ "chat", - "--kanban-worker-launch", task.id, + "--kanban-worker-launch", worker_launch_proof, "-q", prompt, ]) if task.goal_mode: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index eccff5173331..7646c72d3875 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2872,8 +2872,8 @@ def _drop_inherited_kanban_lifecycle(args) -> None: A Kanban worker that shells out to ``hermes chat --source tool`` (Browser Use benchmarks, local-model evals) used to keep ``HERMES_KANBAN_TASK`` and could ``kanban_complete`` the parent card. A real worker needs matching - source, task-bound env marker, and hidden CLI launch argument; query text - is never authority. + source, a task id, and the same one-shot nonce in env and hidden CLI launch + argument; query text and the public task id are never launch proof. """ try: from agent.delegation_context import ( diff --git a/tests/conftest.py b/tests/conftest.py index 00644ab7e663..233586e8327c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -310,6 +310,7 @@ def _looks_like_credential(name: str) -> bool: "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_LOGS_ROOT", "HERMES_KANBAN_TASK", + "HERMES_KANBAN_WORKER_LAUNCH", "HERMES_KANBAN_WORKSPACE", "HERMES_KANBAN_RUN_ID", "HERMES_KANBAN_CLAIM_LOCK", @@ -1393,7 +1394,7 @@ def _guarded_killpg(pgid, sig, *args, **kwargs): return real_killpg(pgid, sig, *args, **kwargs) raise RuntimeError( f"tests/conftest.py live-system guard: blocked " - f"os.killpg({pgid}, {sig}) — PGID is outside the test " + f"os.killpg({pgid}, {sig}) — PGID is outside the test " # windows-footgun: ok — diagnostic text "process group. See _live_system_guard for the why." ) diff --git a/tests/hermes_cli/test_kanban_child_chat_startup.py b/tests/hermes_cli/test_kanban_child_chat_startup.py index a90f6c0ca0f2..5a71a57b7e69 100644 --- a/tests/hermes_cli/test_kanban_child_chat_startup.py +++ b/tests/hermes_cli/test_kanban_child_chat_startup.py @@ -4,7 +4,7 @@ ``tests/tools/test_kanban_child_chat_env_isolation.py``: even if a child process is launched with a raw ``os.environ`` copy (outside the terminal sanitize path), ``cmd_chat`` must refuse to become the board worker unless -it carries the task-bound machine marker emitted by the native dispatcher. +it carries the one-shot native launch proof emitted by the dispatcher. """ from __future__ import annotations @@ -185,13 +185,13 @@ def test_explicit_board_worker_launch_keeps_ownership_for_any_query_wording( monkeypatch, tmp_path, task_id="t_board_worker", - launch_marker="t_board_worker", + launch_marker="launch-nonce-a", ) main_mod.cmd_chat( _chat_args( source=None, query="execute the assigned board card now", - kanban_worker_launch="t_board_worker", + kanban_worker_launch="launch-nonce-a", ) ) @@ -206,14 +206,15 @@ def test_explicit_board_worker_launch_keeps_ownership_for_any_query_wording( @pytest.mark.parametrize( "source, launch_marker, launch_arg", [ - ("kanban", "t_board_worker", None), - ("kanban", None, "t_board_worker"), - ("kanban", "", "t_board_worker"), - ("kanban", "t_other", "t_board_worker"), - ("kanban", "t_board_worker", ""), - ("kanban", "t_board_worker", "t_other"), - ("tool", "t_board_worker", "t_board_worker"), - (" kanban ", "t_board_worker", "t_board_worker"), + ("kanban", "launch-nonce-a", None), + ("kanban", None, "launch-nonce-a"), + ("kanban", "", "launch-nonce-a"), + ("kanban", "launch-nonce-a", "launch-nonce-b"), + # The task id is public lifecycle data, not a launch proof. + ("kanban", "t_board_worker", "t_board_worker"), + ("kanban", "launch-nonce-a", ""), + ("tool", "launch-nonce-a", "launch-nonce-a"), + (" kanban ", "launch-nonce-a", "launch-nonce-a"), ], ) def test_source_task_marker_and_flag_must_all_match( @@ -248,22 +249,23 @@ def test_consumed_marker_cannot_authorize_a_nested_chat( monkeypatch, tmp_path, task_id="t_board_worker", - launch_marker="t_board_worker", + launch_marker="launch-nonce-a", ) main_mod.cmd_chat(_chat_args( source="kanban", query="arbitrary worker wording", - kanban_worker_launch="t_board_worker", + kanban_worker_launch="launch-nonce-a", )) assert fake_cli["env"]["HERMES_KANBAN_TASK"] == "t_board_worker" assert os.environ.get("HERMES_KANBAN_WORKER_LAUNCH") is None - # A raw-env child can inherit the runtime task keys, but the consumed env - # marker and one-shot internal argv proof are both gone. + # A raw-env child can inherit the runtime task keys. Even if it reconstructs + # both proof positions from the public task id, startup must reject it. + monkeypatch.setenv("HERMES_KANBAN_WORKER_LAUNCH", "t_board_worker") main_mod.cmd_chat(_chat_args( source="kanban", query="work kanban task t_board_worker", - kanban_worker_launch=None, + kanban_worker_launch="t_board_worker", )) for key in _OWNERSHIP_KEYS: assert fake_cli["env"].get(key) is None, key @@ -274,8 +276,8 @@ def test_internal_worker_launch_flag_is_accepted_but_hidden_from_help(): parser, _subparsers, chat_parser = build_top_level_parser() args = parser.parse_args([ - "chat", "--kanban-worker-launch", "t_worker", "-q", "anything", + "chat", "--kanban-worker-launch", "launch-nonce-a", "-q", "anything", ]) - assert args.kanban_worker_launch == "t_worker" + assert args.kanban_worker_launch == "launch-nonce-a" assert "--kanban-worker-launch" not in chat_parser.format_help() diff --git a/tests/tools/test_kanban_child_chat_env_isolation.py b/tests/tools/test_kanban_child_chat_env_isolation.py index 2e758055af12..d76cb2fc2ed8 100644 --- a/tests/tools/test_kanban_child_chat_env_isolation.py +++ b/tests/tools/test_kanban_child_chat_env_isolation.py @@ -146,6 +146,23 @@ def test_hermes_subprocess_env_strips_ownership(self, tmp_path): assert child["BROWSERBASE_API_KEY"] == "bb-keep" assert child["FIRECRAWL_API_KEY"] == "fc-keep" + def test_local_import_failure_still_scrubs_launch_proof(self, monkeypatch, tmp_path): + import builtins + from tools.environments import local + + real_import = builtins.__import__ + + def _fail_helper_import(name, *args, **kwargs): + if name == "agent.delegation_context": + raise ImportError("helper unavailable") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _fail_helper_import) + child = local._scrub_kanban_lifecycle_ownership(_worker_env(tmp_path)) + _assert_ownership_stripped(child) + assert child["HERMES_KANBAN_BOARD"] == "default" + assert child["HERMES_KANBAN_DB"].endswith("kanban.db") + def test_lifecycle_keys_are_a_subset_of_full_kanban_env_keys(): from agent.delegation_context import ( @@ -159,21 +176,22 @@ def test_lifecycle_keys_are_a_subset_of_full_kanban_env_keys(): assert "HERMES_KANBAN_DB" not in KANBAN_LIFECYCLE_OWNERSHIP_KEYS -def test_default_spawn_still_injects_worker_ownership(monkeypatch, tmp_path): - """Dispatcher-owned workers must still receive lifecycle vars.""" +def test_default_spawn_mints_fresh_one_shot_launch_proof(monkeypatch, tmp_path): + """Each native spawn pairs one fresh nonce across env and hidden argv.""" from hermes_cli import kanban_db as kb - captured = {} + captured = [] + nonces = iter(("launch-nonce-a", "launch-nonce-b")) class _Proc: pid = 4242 def _fake_popen(cmd, **kwargs): - captured["cmd"] = cmd - captured["env"] = kwargs["env"] + captured.append({"cmd": cmd, "env": kwargs["env"], "kwargs": kwargs}) return _Proc() monkeypatch.setattr("subprocess.Popen", _fake_popen) + monkeypatch.setattr("secrets.token_urlsafe", lambda _bytes: next(nonces)) monkeypatch.setattr(kb, "_retag_legacy_worker_sessions", lambda _root: None) monkeypatch.setattr(kb, "worker_logs_dir", lambda board=None: tmp_path / "logs") @@ -198,18 +216,23 @@ def _fake_popen(cmd, **kwargs): workspace = tmp_path / "ws" workspace.mkdir() kb._default_spawn(task, str(workspace)) + kb._default_spawn(task, str(workspace)) - env = captured["env"] - assert env["HERMES_KANBAN_TASK"] == "t_board_worker" - assert env["HERMES_KANBAN_RUN_ID"] == "7" - assert env["HERMES_KANBAN_WORKSPACE"] == str(workspace) - assert env["HERMES_KANBAN_CLAIM_LOCK"] == "lock-worker" - assert env["HERMES_KANBAN_WORKER_LAUNCH"] == "t_board_worker" - assert env["HERMES_SESSION_SOURCE"] == "kanban" - assert captured["cmd"][-5:] == [ - "chat", "--kanban-worker-launch", "t_board_worker", - "-q", "work kanban task t_board_worker", - ] + assert len(captured) == 2 + for call, nonce in zip(captured, ("launch-nonce-a", "launch-nonce-b")): + env = call["env"] + assert env["HERMES_KANBAN_TASK"] == "t_board_worker" + assert env["HERMES_KANBAN_RUN_ID"] == "7" + assert env["HERMES_KANBAN_WORKSPACE"] == str(workspace) + assert env["HERMES_KANBAN_CLAIM_LOCK"] == "lock-worker" + assert env["HERMES_KANBAN_WORKER_LAUNCH"] == nonce + assert env["HERMES_KANBAN_WORKER_LAUNCH"] != task.id + launch_index = call["cmd"].index("--kanban-worker-launch") + assert call["cmd"][launch_index + 1] == nonce + assert call["kwargs"]["start_new_session"] is True + assert captured[0]["env"]["HERMES_KANBAN_WORKER_LAUNCH"] != ( + captured[1]["env"]["HERMES_KANBAN_WORKER_LAUNCH"] + ) def test_terminal_child_cannot_complete_parent_and_parent_stays_running(