diff --git a/tests/tools/test_delegate_toolset_scope.py b/tests/tools/test_delegate_toolset_scope.py index fd90dc1b5612..ea3f339b84e7 100644 --- a/tests/tools/test_delegate_toolset_scope.py +++ b/tests/tools/test_delegate_toolset_scope.py @@ -54,6 +54,32 @@ def test_strip_blocked_removes_delegation(self): assert "memory" not in child assert "terminal" in child + def test_strip_blocked_removes_kanban(self): + """The kanban toolset must never pass through to a delegated child. + + A child inherits the parent's ``HERMES_KANBAN_TASK`` env, so a child + that also held ``kanban_complete`` / ``kanban_block`` would resolve + the *parent's* card and could falsely flip it to done with the + child's summary (control-surface-bleed / false-done). Children get no + card-terminal authority. + """ + child = _strip_blocked_tools(["terminal", "file", "kanban"]) + assert "kanban" not in child + assert "terminal" in child + assert "file" in child + + def test_kanban_stripped_even_when_inherited_from_parent(self): + """No-toolsets-requested inheritance path must also drop kanban. + + When ``toolsets`` is None the child inherits the parent's full set + through ``_strip_blocked_tools`` — the kanban strip has to hold on + that path too, not just when the LLM explicitly requests it. + """ + parent_toolsets = ["terminal", "file", "kanban", "web"] + child = _strip_blocked_tools(parent_toolsets) + assert "kanban" not in child + assert sorted(child) == ["file", "terminal", "web"] + def test_empty_intersection_yields_empty_toolsets(self): """If parent has no overlap with requested, child gets nothing extra.""" parent = SimpleNamespace(enabled_toolsets=["terminal"]) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 893502ec04ff..f33aba6a6478 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -769,13 +769,23 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]: """Remove toolsets that contain only blocked tools. The strip set is derived from DELEGATE_BLOCKED_TOOLS plus the explicit - composite/scenario toolsets (delegation, code_execution) that have no - one-to-one tool. This keeps the blocklist and the strip set in lockstep + composite/scenario toolsets (delegation, code_execution, kanban) that have + no one-to-one tool. This keeps the blocklist and the strip set in lockstep so new blocked tools can't silently leak through as toolset names. + + ``kanban`` is stripped unconditionally — even for orchestrator children. + A delegated child inherits the parent's ``HERMES_KANBAN_TASK`` env, so a + child holding the kanban terminal verbs (``kanban_complete`` / + ``kanban_block``) would resolve the *parent's* card and could retire it + with the child's summary while the real deliverable was never produced + (false-done, control-surface-bleed). Children get no card-terminal + authority. """ # Composite toolsets that should never pass through to children, even # though their individual tools aren't all in DELEGATE_BLOCKED_TOOLS. - _COMPOSITE_BLOCKED_TOOLSETS = frozenset({"delegation", "code_execution"}) + _COMPOSITE_BLOCKED_TOOLSETS = frozenset( + {"delegation", "code_execution", "kanban"} + ) blocked_toolset_names = { name for name, defn in TOOLSETS.items()