From 2d46af3fa2dccd757663b30898768805656cd54d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:13:35 -0500 Subject: [PATCH] fix(kanban): per-home dispatch claim allowlist for shared boards On a shared kanban.db, every home's profile_exists('default') is unconditionally True, so any home's dispatcher could claim cards assigned to 'default'. Wrap the _profile_exists_fn() predicate with an optional per-home allowlist: kanban.dispatch_profiles (config.yaml, list or comma-separated string) with a HERMES_KANBAN_DISPATCH_PROFILES env bridge. Unset preserves upstream behavior; 'none' claims nothing. Foreign assignees land in the existing skipped_nonspawnable bucket, and the gate applies to the ready spawn path, _has_spawnable, and review dispatch alike. Also documents the multi-home default collision in the kanban user guide. Fixes #110995 --- hermes_cli/config_defaults.py | 7 ++ hermes_cli/kanban_db_dispatch.py | 76 ++++++++++++++++++- .../test_kanban_dispatch_claim_allowlist.py | 69 +++++++++++++++++ website/docs/user-guide/features/kanban.md | 18 +++++ 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 tests/hermes_cli/test_kanban_dispatch_claim_allowlist.py diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index bda7f54fb8..637d0eead4 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1771,6 +1771,13 @@ DEFAULT_CONFIG = { # fan-out workflows that would otherwise saturate one profile's local model / API quota / browser # pool while leaving other profiles idle. See #21582. "max_in_progress_per_profile": None, + # Per-home claim allowlist for shared boards (#110995): profile names this + # home's dispatcher may claim, as a list or comma-separated string. Unset + # (None) = any existing profile is claimable (upstream behavior); + # ["none"] = claim nothing. On a shared kanban.db, every home's + # profile_exists("default") is True, so without this each home can claim + # default-assigned cards. Runtime override: HERMES_KANBAN_DISPATCH_PROFILES. + "dispatch_profiles": None, # Auto-run the decomposer on Triage tasks every tick. False = manual via `hermes kanban # decompose ` or the dashboard's Decompose button. "auto_decompose": True, diff --git a/hermes_cli/kanban_db_dispatch.py b/hermes_cli/kanban_db_dispatch.py index 7a8f7a08a7..c7c91e58e7 100644 --- a/hermes_cli/kanban_db_dispatch.py +++ b/hermes_cli/kanban_db_dispatch.py @@ -1218,12 +1218,82 @@ def check_respawn_guard( def _profile_exists_fn() -> Optional[Callable[[str], bool]]: """``hermes_cli.profiles.profile_exists``, or ``None`` when it cannot be imported (local import avoids a cycle; callers fall back to trusting the - assignee).""" + assignee). + + When a per-home claim allowlist is configured (``kanban.dispatch_profiles`` + or ``HERMES_KANBAN_DISPATCH_PROFILES``, #110995), the returned predicate + additionally requires the assignee to be listed — so a card assigned to + ``default`` is only claimable by homes that opted into it. Foreign + assignees land in the existing ``skipped_nonspawnable`` bucket. + """ try: - from hermes_cli.profiles import profile_exists + from hermes_cli.profiles import normalize_profile_name, profile_exists except Exception: return None - return profile_exists + allowlist = _dispatch_profile_allowlist(normalize_profile_name) + if allowlist is None: + return profile_exists + + def _gated(name: str) -> bool: + try: + canon = normalize_profile_name(name) + except ValueError: + canon = (name or "").strip().lower() + return canon in allowlist and bool(profile_exists(name)) + + return _gated + + +# Env-var bridge for the per-home kanban dispatch claim allowlist (#110995). +# Non-secret behavioral settings live in config.yaml; this is the fleet-friendly +# runtime override (containers set env per home more easily than per-home +# config.yaml edits), mirroring terminal.cwd -> TERMINAL_CWD. +KANBAN_DISPATCH_PROFILES_ENV = "HERMES_KANBAN_DISPATCH_PROFILES" + + +def _dispatch_profile_allowlist(normalize_profile_name) -> Optional[frozenset]: + """Per-home claim allowlist for the kanban dispatcher (#110995). + + On a shared board (one ``kanban.db`` mounted across several Hermes homes), + every home's ``profile_exists`` returns True for ``default`` — the root + profile every home has — so a card assigned to ``default`` is claimable by + every home's dispatcher. A home opts out of foreign claims by declaring + which assignees it may claim, canonically in config.yaml: + + kanban: + dispatch_profiles: ["sage", "researcher"] # or "sage,researcher" + + (``HERMES_KANBAN_DISPATCH_PROFILES`` overrides config at runtime.) + + Returns ``None`` when neither is set (upstream behavior: any existing + profile is claimable). The special value ``none`` (or an empty value) + yields an empty allowlist — the home claims nothing. + """ + raw = os.environ.get(KANBAN_DISPATCH_PROFILES_ENV) + if raw is None: + try: + from hermes_cli.config import load_config + cfg = load_config() + kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} + raw = kanban_cfg.get("dispatch_profiles") + except Exception: + return None + if raw is None: + return None + if isinstance(raw, (list, tuple)): + names = [str(n) for n in raw] + else: + names = str(raw).split(",") + names = [n.strip() for n in names if n.strip()] + if not names or all(n.casefold() == "none" for n in names): + return frozenset() + allowed = set() + for n in names: + try: + allowed.add(normalize_profile_name(n)) + except ValueError: + allowed.add(n.strip().lower()) + return frozenset(allowed) def _has_spawnable(conn: sqlite3.Connection, status: str) -> bool: diff --git a/tests/hermes_cli/test_kanban_dispatch_claim_allowlist.py b/tests/hermes_cli/test_kanban_dispatch_claim_allowlist.py new file mode 100644 index 0000000000..3a6ab905b8 --- /dev/null +++ b/tests/hermes_cli/test_kanban_dispatch_claim_allowlist.py @@ -0,0 +1,69 @@ +"""Per-home kanban dispatch claim allowlist. + +Regression tests for #110995: on a shared kanban board (one kanban.db mounted +across several Hermes homes) every home's ``profile_exists("default")`` is +unconditionally True, so any home's dispatcher could claim cards assigned to +``default``. ``kanban.dispatch_profiles`` (or the +``HERMES_KANBAN_DISPATCH_PROFILES`` env bridge) declares which assignees this +home may claim; anything else lands in ``skipped_nonspawnable``. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db_dispatch as kbd + + +@pytest.fixture +def every_home_has_default(monkeypatch): + """Reproduce the incident premise: ``profile_exists("default")`` is True.""" + from hermes_cli import profiles + monkeypatch.setattr(profiles, "profile_exists", lambda name: True) + + +# Env bridge for the claim allowlist (mirrors +# kanban_db_dispatch.KANBAN_DISPATCH_PROFILES_ENV; spelled out so the tests +# stay red-on-base against code that lacks the constant). +_DISPATCH_PROFILES_ENV = "HERMES_KANBAN_DISPATCH_PROFILES" + + +@pytest.fixture +def no_env_bridge(monkeypatch): + monkeypatch.delenv(_DISPATCH_PROFILES_ENV, raising=False) + + +def test_allowlist_env_restricts_default_claim(monkeypatch, every_home_has_default): + monkeypatch.setenv(_DISPATCH_PROFILES_ENV, "sage,researcher") + claim = kbd._profile_exists_fn() + assert claim is not None + assert claim("sage") is True + assert claim("researcher") is True + # The incident: this home must NOT claim another home's "default". + assert claim("default") is False + + +def test_allowlist_env_none_claims_nothing(monkeypatch, every_home_has_default): + monkeypatch.setenv(_DISPATCH_PROFILES_ENV, "none") + claim = kbd._profile_exists_fn() + assert claim is not None + assert claim("sage") is False + assert claim("default") is False + + +def test_allowlist_config_key_end_to_end(every_home_has_default, no_env_bridge): + """Real config.yaml -> real load_config() -> gated predicate.""" + home = Path(os.environ["HERMES_HOME"]) + (home / "config.yaml").write_text("kanban:\n dispatch_profiles:\n - sage\n") + claim = kbd._profile_exists_fn() + assert claim is not None + assert claim("sage") is True + assert claim("default") is False + + +def test_unset_allowlist_preserves_upstream(every_home_has_default, no_env_bridge): + claim = kbd._profile_exists_fn() + assert claim is not None + assert claim("default") is True diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index 7ac47ba25f..ba314bdc26 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -282,8 +282,26 @@ kanban: review_dispatch: true # default: spawn the assigned profile with # the bundled sdlc-review skill. Set false # for human-only review boards. + dispatch_profiles: null # default: this home may claim cards for any + # existing profile. Set to a list (or + # comma-separated string) of profile names to + # restrict which assignees this home claims. + # ["none"] claims nothing. Override at + # runtime with HERMES_KANBAN_DISPATCH_PROFILES. ``` +### Shared boards across homes + +Mounting one `kanban.db` in several Hermes homes (containers, fleet hosts) shares +the board, but profile names are home-local: every home has a root profile named +`default`, and the dispatcher's spawn gate checks `profile_exists(assignee)` +against the *claiming* home. Without further configuration, every home's +dispatcher considers a card assigned to `default` claimable, so the wrong home +can claim and run it. Either give each home unique profile names, or set +`kanban.dispatch_profiles` per home to declare exactly which assignees that home +may claim — anything else lands in the dispatcher's `skipped_nonspawnable` +bucket instead of spawning. + Override the config flag at runtime via `HERMES_KANBAN_DISPATCH_IN_GATEWAY=0` for debugging. Standard gateway supervision applies: run `hermes gateway start` directly, or wire the gateway up as a systemd user unit (see the