{"repo":"NousResearch/hermes-agent","public_id":"19534","display_label":"Kanban worker permissions and task-ownership controls","summary":"These items address enforcing task-ownership rules for workers on destructive operations, preventing mutation of arbitrary task IDs, and adding reassign/archive tools to close gaps in coordinator access controls.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19713,"merged_at":"2026-05-04T11:54:02+00:00"},"resolution":{"state":"resolved","by":"pr-19713"},"freshness":{"state":"verified","last_verified_at":"2026-05-04T11:54:02+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":19534,"pr_number":19713,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-04T11:54:02+00:00"}],"members":[{"kind":"issue","number":19534,"title":"fix(kanban): prevent workers from mutating arbitrary task IDs","url":null,"state":"closed"},{"kind":"pr","number":19713,"title":"fix(kanban): enforce worker task-ownership on destructive tool calls","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_kanban_tools.py","tools/kanban_tools.py"],"fix_hunks":[{"file":"tests/tools/test_kanban_tools.py","hunks":["@@ -492,3 +492,121 @@ def test_kanban_guidance_prompt_size_bounded(monkeypatch, tmp_path):\n     assert 1_500 < len(KANBAN_GUIDANCE) < 4_096, (\n         f\"KANBAN_GUIDANCE is {len(KANBAN_GUIDANCE)} chars — too short (missing?) or too long\"\n     )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Worker task-ownership enforcement (regression tests for #19534)\n+# ---------------------------------------------------------------------------\n+#\n+# A worker process has HERMES_KANBAN_TASK set to its own task id. The\n+# destructive tools (kanban_complete, kanban_block, kanban_heartbeat)\n+# must refuse to operate on any OTHER task id, even if the caller\n+# supplies an explicit `task_id` argument. Workers legitimately call\n+# kanban_show / kanban_comment / kanban_create / kanban_link on other\n+# tasks, so those are unrestricted.\n+#\n+# Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally\n+# exempt — their job is routing, and they sometimes close out child\n+# tasks on behalf of the child.\n+\n+\n+def test_worker_complete_rejects_foreign_task_id(worker_env):\n+    \"\"\"A worker cannot complete a task that isn't its own (#19534).\"\"\"\n+    from hermes_cli import kanban_db as kb\n+    conn = kb.connect()\n+    try:\n+        other = kb.create_task(conn, title=\"sibling\")\n+        conn.execute(\"UPDATE tasks SET status='ready' WHERE id=?\", (other,))\n+        conn.commit()\n+    finally:\n+        conn.close()\n+\n+    from tools import kanban_tools as kt\n+    out = kt._handle_complete({\"task_id\": other, \"summary\": \"HIJACK\"})\n+    d = json.loads(out)\n+    assert d.get(\"ok\") is not True\n+    assert \"refusing to mutate\" in d.get(\"error\", \"\")\n+\n+    # Sibling task must be untouched.\n+    conn = kb.connect()\n+    try:\n+        assert kb.get_task(conn, other).status == \"ready\"\n+    finally:\n+        conn.close()\n+\n+\n+def test_worker_block_rejects_foreign_task_id(worker_env):\n+    \"\"\"A worker cannot block a task that isn't its own (#19534).\"\"\"\n+    from hermes_cli import kanban_db as kb\n+    conn = kb.connect()\n+    try:\n+        other = kb.create_task(conn, title=\"sibling\")\n+        conn.execute(\"UPDATE tasks SET status='ready' WHERE id=?\", (other,))\n+        conn.commit()\n+    finally:\n+        conn.close()\n+\n+    from tools import kanban_tools as kt\n+    out = kt._handle_block({\"task_id\": other, \"reason\": \"evil\"})\n+    d = json.loads(out)\n+    assert \"refusing to mutate\" in d.get(\"error\", \"\")\n+\n+    conn = kb.connect()\n+    try:\n+        assert kb.get_task(conn, other).status == \"ready\"\n+    finally:\n+        conn.close()\n+\n+\n+def test_worker_heartbeat_rejects_foreign_task_id(worker_env):\n+    \"\"\"A worker cannot heartbeat a task that isn't its own (#19534).\"\"\"\n+    from hermes_cli import kanban_db as kb\n+    conn = kb.connect()\n+    try:\n+        other = kb.create_task(conn, title=\"sibling\")\n+        # Put sibling in running state so heartbeat would otherwise succeed.\n+        conn.execute(\"UPDATE tasks SET status='running' WHERE id=?\", (other,))\n+        conn.commit()\n+    finally:\n+        conn.close()\n+\n+    from tools import kanban_tools as kt\n+    out = kt._handle_heartbeat({\"task_id\": other})\n+    d = json.loads(out)\n+    assert \"refusing to mutate\" in d.get(\"error\", \"\")\n+\n+\n+def test_worker_complete_own_task_still_works(worker_env):\n+    \"\"\"The ownership check doesn't break the normal own-task happy path.\"\"\"\n+    from tools import kanban_tools as kt\n+    # Both implicit (no task_id arg) and explicit (matching env) must work.\n+    out = kt._handle_complete({\"task_id\": worker_env, \"summary\": \"explicit own\"})\n+    d = json.loads(out)\n+    assert d.get(\"ok\") is True and d.get(\"task_id\") == worker_env\n+\n+\n+def test_orchestrator_complete_any_task_allowed(monkeypatch, tmp_path):\n+    \"\"\"Orchestrator profiles (no HERMES_KANBAN_TASK) can still complete\n+    any task via explicit task_id. The check only applies to workers.\"\"\"\n+    monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    from pathlib import Path as _P\n+    monkeypatch.setattr(_P, \"home\", lambda: tmp_path)\n+\n+    from hermes_cli import kanban_db as kb\n+    kb._INITIALIZED_PATHS.clear()\n+    kb.init_db()\n+    conn = kb.connect()\n+    try:\n+        tid = kb.create_task(conn, title=\"child to close out\")\n+        conn.execute(\"UPDATE tasks SET status='ready' WHERE id=?\", (tid,))\n+        conn.commit()\n+    finally:\n+        conn.close()\n+\n+    from tools import kanban_tools as kt\n+    out = kt._handle_complete({\"task_id\": tid, \"summary\": \"orchestrator close\"})\n+    d = json.loads(out)\n+    assert d.get(\"ok\") is True and d.get(\"task_id\") == tid"]},{"file":"tools/kanban_tools.py","hunks":["@@ -79,6 +79,38 @@ def _default_task_id(arg: Optional[str]) -> Optional[str]:\n     return env_tid or None\n \n \n+def _enforce_worker_task_ownership(tid: str) -> Optional[str]:\n+    \"\"\"Reject worker-driven destructive calls on foreign task IDs.\n+\n+    A process spawned by the dispatcher has ``HERMES_KANBAN_TASK`` set\n+    to its own task id. Tools like ``kanban_complete`` / ``kanban_block``\n+    / ``kanban_heartbeat`` mutate run-lifecycle state, so a buggy or\n+    prompt-injected worker that passed an explicit ``task_id`` for some\n+    other task could corrupt sibling or cross-tenant runs (see #19534).\n+\n+    Orchestrator profiles (kanban toolset enabled but **no**\n+    ``HERMES_KANBAN_TASK`` in env) aren't subject to this check — their\n+    job is routing, and they sometimes legitimately close out child\n+    tasks or reopen blocked ones. Workers are narrowly scoped to their\n+    one task.\n+\n+    Returns ``None`` when the call is allowed, or a tool-error string\n+    when it must be rejected. Callers should ``return`` the error\n+    verbatim.\n+    \"\"\"\n+    env_tid = os.environ.get(\"HERMES_KANBAN_TASK\")\n+    if not env_tid:\n+        # Orchestrator or CLI context — no task-scope restriction.\n+        return None\n+    if tid != env_tid:\n+        return tool_error(\n+            f\"worker is scoped to task {env_tid}; refusing to mutate \"\n+            f\"{tid}. Use kanban_comment to hand off information to other \"\n+            f\"tasks, or kanban_create to spawn follow-up work.\"\n+        )\n+    return None\n+\n+\n def _connect():\n     \"\"\"Import + connect lazily so the module imports cleanly in non-kanban\n     contexts (e.g. test rigs that import every tool module).\"\"\"","@@ -172,6 +204,9 @@ def _handle_complete(args: dict, **kw) -> str:\n         return tool_error(\n             \"task_id is required (or set HERMES_KANBAN_TASK in the env)\"\n         )\n+    ownership_err = _enforce_worker_task_ownership(tid)\n+    if ownership_err:\n+        return ownership_err\n     summary = args.get(\"summary\")\n     metadata = args.get(\"metadata\")\n     result = args.get(\"result\")","@@ -210,6 +245,9 @@ def _handle_block(args: dict, **kw) -> str:\n         return tool_error(\n             \"task_id is required (or set HERMES_KANBAN_TASK in the env)\"\n         )\n+    ownership_err = _enforce_worker_task_ownership(tid)\n+    if ownership_err:\n+        return ownership_err\n     reason = args.get(\"reason\")\n     if not reason or not str(reason).strip():\n         return tool_error(\"reason is required — explain what input you need\")","@@ -238,6 +276,9 @@ def _handle_heartbeat(args: dict, **kw) -> str:\n         return tool_error(\n             \"task_id is required (or set HERMES_KANBAN_TASK in the env)\"\n         )\n+    ownership_err = _enforce_worker_task_ownership(tid)\n+    if ownership_err:\n+        return ownership_err\n     note = args.get(\"note\")\n     try:\n         kb, conn = _connect()\n"]}]},{"kind":"pr","number":114252,"title":"kanban: add kanban_reassign/kanban_archive tools to close coordinator fence gap","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"ad972db1c505f4a0a84d62f4ee2695c83d752b26","number":0},{"kind":"commit","value":"07c92d675a821d96e4aabd636bf22bde32167c38","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}