mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
fix(kanban): enforce worker task-ownership on destructive tool calls (#19713)
Closes #19534 (security). A worker spawned by the kanban dispatcher has HERMES_KANBAN_TASK set to its own task id. The destructive tools (kanban_complete, kanban_block, kanban_heartbeat) resolved task_id via _default_task_id() which preferred an explicit arg over the env var, with no ownership check — so a buggy or prompt-injected worker could complete / block / heartbeat any OTHER task (sibling, cross-tenant, anything) by supplying its id. Reporter's repro: worker for t_A passed task_id=t_B to kanban_complete and got {"ok": true}. Fix: add _enforce_worker_task_ownership(tid). If HERMES_KANBAN_TASK is set and tid doesn't match, return a structured tool error with guidance to use kanban_comment (for information handoff across tasks) or kanban_create (for follow-up work). Orchestrator profiles (no env var, but kanban toolset enabled per #18968) are exempt — their job is routing and sometimes includes closing out child tasks. Kept unrestricted (deliberately): - kanban_show — workers legitimately read parent/sibling handoff context - kanban_comment — cross-task comments are the handoff mechanism - kanban_create — orchestrator fan-out, worker follow-up spawning - kanban_link — parent/child linking Tests: 5 new regression tests in tests/tools/test_kanban_tools.py covering the grid (worker-attacks-foreign ×3 tools, worker-own-task preserved, orchestrator-unrestricted). 36/36 pass.
This commit is contained in:
parent
1bd5ac7f2f
commit
d3b22b76d8
2 changed files with 159 additions and 0 deletions
|
|
@ -79,6 +79,38 @@ def _default_task_id(arg: Optional[str]) -> Optional[str]:
|
|||
return env_tid or None
|
||||
|
||||
|
||||
def _enforce_worker_task_ownership(tid: str) -> Optional[str]:
|
||||
"""Reject worker-driven destructive calls on foreign task IDs.
|
||||
|
||||
A process spawned by the dispatcher has ``HERMES_KANBAN_TASK`` set
|
||||
to its own task id. Tools like ``kanban_complete`` / ``kanban_block``
|
||||
/ ``kanban_heartbeat`` mutate run-lifecycle state, so a buggy or
|
||||
prompt-injected worker that passed an explicit ``task_id`` for some
|
||||
other task could corrupt sibling or cross-tenant runs (see #19534).
|
||||
|
||||
Orchestrator profiles (kanban toolset enabled but **no**
|
||||
``HERMES_KANBAN_TASK`` in env) aren't subject to this check — their
|
||||
job is routing, and they sometimes legitimately close out child
|
||||
tasks or reopen blocked ones. Workers are narrowly scoped to their
|
||||
one task.
|
||||
|
||||
Returns ``None`` when the call is allowed, or a tool-error string
|
||||
when it must be rejected. Callers should ``return`` the error
|
||||
verbatim.
|
||||
"""
|
||||
env_tid = os.environ.get("HERMES_KANBAN_TASK")
|
||||
if not env_tid:
|
||||
# Orchestrator or CLI context — no task-scope restriction.
|
||||
return None
|
||||
if tid != env_tid:
|
||||
return tool_error(
|
||||
f"worker is scoped to task {env_tid}; refusing to mutate "
|
||||
f"{tid}. Use kanban_comment to hand off information to other "
|
||||
f"tasks, or kanban_create to spawn follow-up work."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _connect():
|
||||
"""Import + connect lazily so the module imports cleanly in non-kanban
|
||||
contexts (e.g. test rigs that import every tool module)."""
|
||||
|
|
@ -172,6 +204,9 @@ def _handle_complete(args: dict, **kw) -> str:
|
|||
return tool_error(
|
||||
"task_id is required (or set HERMES_KANBAN_TASK in the env)"
|
||||
)
|
||||
ownership_err = _enforce_worker_task_ownership(tid)
|
||||
if ownership_err:
|
||||
return ownership_err
|
||||
summary = args.get("summary")
|
||||
metadata = args.get("metadata")
|
||||
result = args.get("result")
|
||||
|
|
@ -210,6 +245,9 @@ def _handle_block(args: dict, **kw) -> str:
|
|||
return tool_error(
|
||||
"task_id is required (or set HERMES_KANBAN_TASK in the env)"
|
||||
)
|
||||
ownership_err = _enforce_worker_task_ownership(tid)
|
||||
if ownership_err:
|
||||
return ownership_err
|
||||
reason = args.get("reason")
|
||||
if not reason or not str(reason).strip():
|
||||
return tool_error("reason is required — explain what input you need")
|
||||
|
|
@ -238,6 +276,9 @@ def _handle_heartbeat(args: dict, **kw) -> str:
|
|||
return tool_error(
|
||||
"task_id is required (or set HERMES_KANBAN_TASK in the env)"
|
||||
)
|
||||
ownership_err = _enforce_worker_task_ownership(tid)
|
||||
if ownership_err:
|
||||
return ownership_err
|
||||
note = args.get("note")
|
||||
try:
|
||||
kb, conn = _connect()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue