diff --git a/agent/delegation_context.py b/agent/delegation_context.py new file mode 100644 index 000000000000..0b77e72c415b --- /dev/null +++ b/agent/delegation_context.py @@ -0,0 +1,51 @@ +"""Context-local state for delegate_task child execution. + +The parent Hermes process may itself be a Kanban dispatcher worker with +HERMES_KANBAN_* variables in process env. delegate_task children run inside the +same Python process, but they are not dispatcher-owned Kanban workers. This +module lets code paths that resolve tool schemas or spawn subprocesses fail +closed for delegated children without mutating global os.environ for the parent. +""" +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Iterator, Mapping, MutableMapping + +_DELEGATED_CHILD_CONTEXT: ContextVar[bool] = ContextVar( + "hermes_delegated_child_context", + default=False, +) + +KANBAN_ENV_KEYS: tuple[str, ...] = ( + "HERMES_KANBAN_TASK", + "HERMES_KANBAN_RUN_ID", + "HERMES_KANBAN_WORKSPACE", + "HERMES_KANBAN_WORKSPACES_ROOT", + "HERMES_KANBAN_CLAIM_LOCK", + "HERMES_KANBAN_BOARD", + "HERMES_KANBAN_DB", +) + + +@contextmanager +def delegated_child_context() -> Iterator[None]: + """Mark the current execution context as a delegate_task child.""" + token = _DELEGATED_CHILD_CONTEXT.set(True) + try: + yield + finally: + _DELEGATED_CHILD_CONTEXT.reset(token) + + +def is_delegated_child_context() -> bool: + """Return True while code is running for a delegate_task child.""" + return bool(_DELEGATED_CHILD_CONTEXT.get()) + + +def scrub_kanban_env(env: Mapping[str, str] | MutableMapping[str, str]) -> dict[str, str]: + """Return *env* with dispatcher-only Kanban variables removed.""" + cleaned = dict(env) + for key in KANBAN_ENV_KEYS: + cleaned.pop(key, None) + return cleaned diff --git a/model_tools.py b/model_tools.py index dd27fb342cb5..32394a69eec6 100644 --- a/model_tools.py +++ b/model_tools.py @@ -39,6 +39,15 @@ logger = logging.getLogger(__name__) _WARNED_DISABLED_BUNDLES: set = set() +def _is_delegated_child_context() -> bool: + try: + from agent.delegation_context import is_delegated_child_context + + return is_delegated_child_context() + except Exception: + return False + + # ============================================================================= # Async Bridging (single source of truth -- used by registry.dispatch too) # ============================================================================= @@ -323,6 +332,7 @@ def get_tool_definitions( cfg_fp, bool(os.environ.get("HERMES_KANBAN_TASK")), bool(skip_tool_search_assembly), + _is_delegated_child_context(), ) cached = _tool_defs_cache.get(cache_key) if cached is not None: @@ -366,7 +376,11 @@ def _compute_tool_definitions( if enabled_toolsets is not None: effective_enabled_toolsets = list(enabled_toolsets) - if os.environ.get("HERMES_KANBAN_TASK") and "kanban" not in effective_enabled_toolsets: + if ( + os.environ.get("HERMES_KANBAN_TASK") + and not _is_delegated_child_context() + and "kanban" not in effective_enabled_toolsets + ): # Dispatcher-spawned workers are scoped by HERMES_KANBAN_TASK and # must always receive the lifecycle handoff tools. Assignee # profiles may intentionally restrict their normal chat toolsets diff --git a/tests/tools/test_delegate_kanban_isolation.py b/tests/tools/test_delegate_kanban_isolation.py new file mode 100644 index 000000000000..35afa1ef99b0 --- /dev/null +++ b/tests/tools/test_delegate_kanban_isolation.py @@ -0,0 +1,266 @@ +"""Regression tests for delegate_task isolation from parent Kanban workers.""" +from __future__ import annotations + +import json + + +def _make_running_kanban_task(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir() + attachments_root = tmp_path / "attachments" + workspace = tmp_path / "parent-workspace" + workspace.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_PROFILE", "parent-worker") + monkeypatch.setenv("HERMES_KANBAN_WORKSPACE", str(workspace)) + monkeypatch.setenv("HERMES_KANBAN_ATTACHMENTS_ROOT", str(attachments_root)) + + from hermes_cli import kanban_db as kb + + kb._INITIALIZED_PATHS.clear() + kb.init_db() + conn = kb.connect() + try: + tid = kb.create_task( + conn, + title="parent", + assignee="parent-worker", + workspace_kind="scratch", + workspace_path=str(workspace), + ) + claim = kb.claim_task(conn, tid) + assert claim is not None + run_id = claim.id + finally: + conn.close() + + monkeypatch.setenv("HERMES_KANBAN_TASK", tid) + monkeypatch.setenv("HERMES_KANBAN_RUN_ID", str(run_id)) + return kb, tid, workspace, attachments_root + + +def test_delegated_child_context_suppresses_env_gated_kanban_tools(monkeypatch, tmp_path): + """A delegate_task child must not inherit the parent's Kanban tool schema. + + The parent process may be a dispatcher worker with HERMES_KANBAN_TASK set; + the child is only a subagent, not the run owner. + """ + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent") + monkeypatch.setenv("HERMES_KANBAN_RUN_ID", "123") + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + import tools.kanban_tools # noqa: F401 - ensure registered + from agent.delegation_context import delegated_child_context + from model_tools import _clear_tool_defs_cache, get_tool_definitions + from tools.registry import invalidate_check_fn_cache + + invalidate_check_fn_cache() + _clear_tool_defs_cache() + with delegated_child_context(): + schema = get_tool_definitions(enabled_toolsets=["terminal"], quiet_mode=True) + + names = {s["function"].get("name") for s in schema if "function" in s} + assert "terminal" in names + assert {n for n in names if n and n.startswith("kanban_")} == set() + + +def test_build_child_agent_strips_kanban_toolset_even_when_parent_is_worker(monkeypatch): + """Child construction must fail closed even if the parent exposes kanban.""" + captured = {} + + class FakeAgent: + def __init__(self, **kwargs): + captured.update(kwargs) + self.valid_tool_names = {"terminal"} + self.session_id = "child-session" + + import run_agent + from tools import delegate_tool + + monkeypatch.setattr(run_agent, "AIAgent", FakeAgent) + monkeypatch.setattr(delegate_tool, "_load_config", lambda: {}) + + class Parent: + enabled_toolsets = ["terminal", "kanban"] + valid_tool_names = {"terminal", "kanban_complete", "kanban_comment"} + model = "test-model" + provider = "test-provider" + base_url = "http://example.invalid" + api_mode = "chat_completions" + platform = "cli" + session_id = "parent-session" + + child = delegate_tool._build_child_agent( + task_index=0, + goal="review only", + context=None, + toolsets=None, + model=None, + max_iterations=3, + task_count=1, + parent_agent=Parent(), + ) + + assert child.valid_tool_names == {"terminal"} + assert "kanban" not in captured["enabled_toolsets"] + assert "kanban" in captured["disabled_toolsets"] + + +def test_delegate_child_terminal_env_scrubs_parent_kanban_keys(monkeypatch): + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent") + monkeypatch.setenv("HERMES_KANBAN_RUN_ID", "123") + monkeypatch.setenv("HERMES_KANBAN_WORKSPACE", "/tmp/parent-workspace") + monkeypatch.setenv("HERMES_KANBAN_CLAIM_LOCK", "lock") + + from agent.delegation_context import delegated_child_context + from tools.environments.local import _sanitize_subprocess_env + + with delegated_child_context(): + env = _sanitize_subprocess_env({ + "HERMES_KANBAN_TASK": "t_parent", + "HERMES_KANBAN_RUN_ID": "123", + "HERMES_KANBAN_WORKSPACE": "/tmp/parent-workspace", + "HERMES_KANBAN_CLAIM_LOCK": "lock", + "PATH": "/usr/bin", + }) + + assert env["PATH"] == "/usr/bin" + assert "HERMES_KANBAN_TASK" not in env + assert "HERMES_KANBAN_RUN_ID" not in env + assert "HERMES_KANBAN_WORKSPACE" not in env + assert "HERMES_KANBAN_CLAIM_LOCK" not in env + + +def test_delegate_child_kanban_mutator_guard_rejects_explicit_task_id(monkeypatch): + """Defense in depth: direct handler access still cannot mutate a board.""" + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent") + from agent.delegation_context import delegated_child_context + from tools import kanban_tools + + with delegated_child_context(): + raw = kanban_tools._handle_complete({ + "task_id": "t_parent", + "summary": "should not complete", + }) + + payload = json.loads(raw) + assert payload["error"] + assert "delegate_task child" in payload["error"] + + +def test_delegate_child_attach_guard_leaves_no_row_or_file(monkeypatch, tmp_path): + kb, tid, _workspace, attachments_root = _make_running_kanban_task(monkeypatch, tmp_path) + + from agent.delegation_context import delegated_child_context + from tools import kanban_tools + + with delegated_child_context(): + raw = kanban_tools._handle_attach({ + "task_id": tid, + "filename": "leak.txt", + "content_base64": "bGVhay1ieXRlcw==", + "content_type": "text/plain", + }) + + payload = json.loads(raw) + assert payload["error"] + assert "delegate_task child" in payload["error"] + + conn = kb.connect() + try: + assert kb.list_attachments(conn, tid) == [] + finally: + conn.close() + task_dir = attachments_root / tid + assert not task_dir.exists() or list(task_dir.iterdir()) == [] + + +def test_delegate_child_attach_url_guard_leaves_no_row_or_file(monkeypatch, tmp_path): + kb, tid, _workspace, attachments_root = _make_running_kanban_task(monkeypatch, tmp_path) + + from agent.delegation_context import delegated_child_context + from tools import kanban_tools + + def forbidden_download(*_args, **_kwargs): + raise AssertionError("delegated child guard must run before URL download") + + monkeypatch.setattr(kanban_tools, "_download_url_with_cap", forbidden_download) + + with delegated_child_context(): + raw = kanban_tools._handle_attach_url({ + "task_id": tid, + "url": "https://example.com/leak.txt", + }) + + payload = json.loads(raw) + assert payload["error"] + assert "delegate_task child" in payload["error"] + + conn = kb.connect() + try: + assert kb.list_attachments(conn, tid) == [] + finally: + conn.close() + task_dir = attachments_root / tid + assert not task_dir.exists() or list(task_dir.iterdir()) == [] + + +def test_child_attempting_default_complete_does_not_finish_parent_or_delete_workspace( + monkeypatch, + tmp_path, +): + """Deterministic E2E: a delegated child cannot complete its parent task.""" + kb, tid, workspace, _attachments_root = _make_running_kanban_task(monkeypatch, tmp_path) + from tools import delegate_tool + from tools import kanban_tools + + class Parent: + _current_task_id = tid + + def _touch_activity(self, _desc): + return None + + class Child: + tool_progress_callback = None + _delegate_saved_tool_names = [] + _credential_pool = None + _subagent_id = "sa-test" + _delegate_depth = 1 + _parent_subagent_id = None + model = "test-model" + session_prompt_tokens = 0 + session_completion_tokens = 0 + session_estimated_cost_usd = 0.0 + session_reasoning_tokens = 0 + + def get_activity_summary(self): + return {"api_call_count": 0, "max_iterations": 1, "current_tool": None} + + def run_conversation(self, user_message, task_id, **_kwargs): + attempted = kanban_tools._handle_complete({"summary": "wrong child completion"}) + return { + "final_response": attempted, + "completed": True, + "api_calls": 0, + "messages": [], + } + + def close(self): + return None + + result = delegate_tool._run_single_child(0, "try to complete parent", Child(), Parent()) + + conn = kb.connect() + try: + task = kb.get_task(conn, tid) + run = kb.latest_run(conn, tid) + finally: + conn.close() + + assert result["status"] == "completed" + assert "delegate_task child" in result["summary"] + assert task.status == "running" + assert run.status == "running" + assert workspace.is_dir() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index f8f1be7246af..e64da983f59d 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -780,6 +780,7 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]: if name in _COMPOSITE_BLOCKED_TOOLSETS or all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", [])) } + blocked_toolset_names.add("kanban") return [t for t in toolsets if t not in blocked_toolset_names] @@ -1175,7 +1176,7 @@ def _build_child_agent( ] child_disabled_toolsets = list( dict.fromkeys( - inherited_disabled + _blocked_toolsets_for_role(effective_role) + inherited_disabled + _blocked_toolsets_for_role(effective_role) + ["kanban"] ) ) @@ -1360,47 +1361,50 @@ def _build_child_agent( if isinstance(child_max_tokens, int): child_optional_kwargs["max_tokens"] = child_max_tokens - child = AIAgent( - base_url=effective_base_url, - api_key=effective_api_key, - model=effective_model, - provider=effective_provider, - api_mode=effective_api_mode, - acp_command=effective_acp_command, - acp_args=effective_acp_args, - max_iterations=max_iterations, + from agent.delegation_context import delegated_child_context - reasoning_config=child_reasoning, - prefill_messages=getattr(parent_agent, "prefill_messages", None), - fallback_model=parent_fallback, - enabled_toolsets=child_toolsets, - disabled_toolsets=child_disabled_toolsets, - quiet_mode=True, - ephemeral_system_prompt=child_prompt, - log_prefix=f"[subagent-{task_index}]", - platform="subagent", - skip_context_files=True, - skip_memory=True, - clarify_callback=None, - thinking_callback=child_thinking_cb, - session_db=getattr(parent_agent, "_session_db", None), - parent_session_id=getattr(parent_agent, "session_id", None), - providers_allowed=child_providers_allowed, - providers_ignored=child_providers_ignored, - providers_order=child_providers_order, - provider_sort=child_provider_sort, - provider_require_parameters=child_provider_require_parameters, - provider_data_collection=child_provider_data_collection, - request_overrides=( - dict(override_request_overrides or {}) - if override_provider - else dict(getattr(parent_agent, "request_overrides", {}) or {}) - ), - openrouter_min_coding_score=child_openrouter_min_coding_score, - tool_progress_callback=child_progress_cb, - iteration_budget=None, # fresh budget per subagent - **child_optional_kwargs, - ) + with delegated_child_context(): + child = AIAgent( + base_url=effective_base_url, + api_key=effective_api_key, + model=effective_model, + provider=effective_provider, + api_mode=effective_api_mode, + acp_command=effective_acp_command, + acp_args=effective_acp_args, + max_iterations=max_iterations, + + reasoning_config=child_reasoning, + prefill_messages=getattr(parent_agent, "prefill_messages", None), + fallback_model=parent_fallback, + enabled_toolsets=child_toolsets, + disabled_toolsets=child_disabled_toolsets, + quiet_mode=True, + ephemeral_system_prompt=child_prompt, + log_prefix=f"[subagent-{task_index}]", + platform="subagent", + skip_context_files=True, + skip_memory=True, + clarify_callback=None, + thinking_callback=child_thinking_cb, + session_db=getattr(parent_agent, "_session_db", None), + parent_session_id=getattr(parent_agent, "session_id", None), + providers_allowed=child_providers_allowed, + providers_ignored=child_providers_ignored, + providers_order=child_providers_order, + provider_sort=child_provider_sort, + provider_require_parameters=child_provider_require_parameters, + provider_data_collection=child_provider_data_collection, + request_overrides=( + dict(override_request_overrides or {}) + if override_provider + else dict(getattr(parent_agent, "request_overrides", {}) or {}) + ), + openrouter_min_coding_score=child_openrouter_min_coding_score, + tool_progress_callback=child_progress_cb, + iteration_budget=None, # fresh budget per subagent + **child_optional_kwargs, + ) child._print_fn = getattr(parent_agent, "_print_fn", None) # Now the child exists, its session id can ride on every relayed event # (including the spawn_requested below — first emit happens after this). @@ -2001,11 +2005,14 @@ def _run_single_child( def _run_with_thread_capture(): _worker_thread_holder["t"] = threading.current_thread() - return child.run_conversation( - user_message=goal, - task_id=child_task_id, - stream_callback=_relay_child_text, - ) + from agent.delegation_context import delegated_child_context + + with delegated_child_context(): + return child.run_conversation( + user_message=goal, + task_id=child_task_id, + stream_callback=_relay_child_text, + ) _child_future = _timeout_executor.submit(_run_with_thread_capture) try: diff --git a/tools/environments/local.py b/tools/environments/local.py index 8b4450c72010..595722d97f08 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -490,6 +490,17 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non _apply_windows_msys_bash_env_defaults(sanitized) + try: + from agent.delegation_context import ( + is_delegated_child_context, + scrub_kanban_env, + ) + + if is_delegated_child_context(): + sanitized = scrub_kanban_env(sanitized) + except Exception: + pass + return sanitized diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 7e6c9ef7a359..599d9e8acb16 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -62,6 +62,33 @@ def _profile_has_kanban_toolset() -> bool: return False +def _is_delegated_child_context() -> bool: + try: + from agent.delegation_context import is_delegated_child_context + + return is_delegated_child_context() + except Exception: + return False + + +def _reject_delegated_child_mutation(tool_name: str) -> Optional[str]: + """Deny Kanban mutations from delegate_task children. + + A delegate_task child runs in the same process as its parent, so stale or + inherited HERMES_KANBAN_* env vars are not proof of dispatcher ownership. + The child may summarize findings to its parent, but it must not complete, + block, heartbeat, comment, create, link, or unblock board tasks directly. + """ + if not _is_delegated_child_context(): + return None + return tool_error( + f"{tool_name} refused: delegate_task child agents are not Kanban " + "run owners. Return findings to the parent agent; the dispatcher " + "worker or an explicitly configured Kanban orchestrator must perform " + "board mutations." + ) + + def _check_kanban_mode() -> bool: """Task-lifecycle tools are available when: @@ -74,6 +101,8 @@ def _check_kanban_mode() -> bool: embedded by default) and orchestrator profiles with the kanban toolset enabled see the Kanban lifecycle tool surface. """ + if _is_delegated_child_context(): + return False if os.environ.get("HERMES_KANBAN_TASK"): return True return _profile_has_kanban_toolset() @@ -88,6 +117,8 @@ def _check_kanban_orchestrator_mode() -> bool: board state. Profiles that explicitly opt into the kanban toolset and are NOT scoped to a single task are the orchestrator surface. """ + if _is_delegated_child_context(): + return False if os.environ.get("HERMES_KANBAN_TASK"): return False return _profile_has_kanban_toolset() @@ -101,6 +132,8 @@ def _default_task_id(arg: Optional[str]) -> Optional[str]: """Resolve ``task_id`` arg or fall back to the env var the dispatcher set.""" if arg: return arg + if _is_delegated_child_context(): + return None env_tid = os.environ.get("HERMES_KANBAN_TASK") return env_tid or None @@ -505,6 +538,9 @@ def _handle_list(args: dict, **kw) -> str: def _handle_complete(args: dict, **kw) -> str: """Mark the current task done with a structured handoff.""" + delegated_err = _reject_delegated_child_mutation("kanban_complete") + if delegated_err: + return delegated_err tid = _default_task_id(args.get("task_id")) if not tid: return tool_error( @@ -681,6 +717,9 @@ def _handle_complete(args: dict, **kw) -> str: def _handle_block(args: dict, **kw) -> str: """Transition the task to blocked with a reason a human will read.""" + delegated_err = _reject_delegated_child_mutation("kanban_block") + if delegated_err: + return delegated_err tid = _default_task_id(args.get("task_id")) if not tid: return tool_error( @@ -767,6 +806,9 @@ def _handle_heartbeat(args: dict, **kw) -> str: by ``release_stale_claims`` — which is exactly the trap that ``heartbeat_claim``'s docstring warns against. """ + delegated_err = _reject_delegated_child_mutation("kanban_heartbeat") + if delegated_err: + return delegated_err tid = _default_task_id(args.get("task_id")) if not tid: return tool_error( @@ -810,6 +852,9 @@ def _handle_heartbeat(args: dict, **kw) -> str: def _handle_comment(args: dict, **kw) -> str: """Append a comment to a task's thread.""" + delegated_err = _reject_delegated_child_mutation("kanban_comment") + if delegated_err: + return delegated_err tid = args.get("task_id") if not tid: return tool_error( @@ -855,6 +900,9 @@ def _handle_attach(args: dict, **kw) -> str: """ from hermes_cli import kanban_db as kb + delegated_err = _reject_delegated_child_mutation("kanban_attach") + if delegated_err: + return delegated_err tid = _default_task_id(args.get("task_id")) if not tid: return tool_error( @@ -974,6 +1022,9 @@ def _handle_attach_url(args: dict, **kw) -> str: """ from hermes_cli import kanban_db as kb + delegated_err = _reject_delegated_child_mutation("kanban_attach_url") + if delegated_err: + return delegated_err tid = _default_task_id(args.get("task_id")) if not tid: return tool_error( @@ -1070,6 +1121,9 @@ def _handle_create(args: dict, **kw) -> str: ``parents`` can be a list of task ids; dependency-gated promotion works as usual. """ + delegated_err = _reject_delegated_child_mutation("kanban_create") + if delegated_err: + return delegated_err title = args.get("title") if not title or not str(title).strip(): return tool_error("title is required") @@ -1290,6 +1344,9 @@ def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool: def _handle_unblock(args: dict, **kw) -> str: """Transition a blocked task to ready, or todo while parents remain open.""" + delegated_err = _reject_delegated_child_mutation("kanban_unblock") + if delegated_err: + return delegated_err guard = _require_orchestrator_tool("kanban_unblock") if guard: return guard @@ -1319,6 +1376,9 @@ def _handle_unblock(args: dict, **kw) -> str: def _handle_link(args: dict, **kw) -> str: """Add a parent→child dependency edge after the fact.""" + delegated_err = _reject_delegated_child_mutation("kanban_link") + if delegated_err: + return delegated_err parent_id = args.get("parent_id") child_id = args.get("child_id") if not parent_id or not child_id: diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index ab02bc21fa56..60d345e1e1fb 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -69,7 +69,7 @@ Or in-session: | `context_engine` | (varies) | Runtime tools exposed by the active context-engine plugin (empty until a plugin populates it). | | `image_gen` | `image_generate` | Text-to-image generation via FAL.ai (with opt-in OpenAI / xAI backends). | | `video_gen` | `video_generate` | Text-to-video and image-to-video via plugin-registered backends (xAI Grok-Imagine, FAL.ai Veo 3.1 / Pixverse v6 / Kling O3). Pass `image_url` to animate an image; omit it for text-to-video. | -| `kanban` | `kanban_block`, `kanban_comment`, `kanban_complete`, `kanban_create`, `kanban_heartbeat`, `kanban_link`, `kanban_list`, `kanban_show`, `kanban_unblock` | Multi-agent coordination tools. Registered for dispatcher-spawned task workers (`HERMES_KANBAN_TASK`) and for profiles that explicitly list the `kanban` toolset by name (the `all`/`*` wildcard does **not** enable it). Workers mark tasks done, block, heartbeat, comment, and create/link follow-up tasks; orchestrator profiles additionally get board-routing tools like list/unblock. | +| `kanban` | `kanban_block`, `kanban_comment`, `kanban_complete`, `kanban_create`, `kanban_heartbeat`, `kanban_link`, `kanban_list`, `kanban_show`, `kanban_unblock` | Multi-agent coordination tools. Registered for dispatcher-spawned task workers (`HERMES_KANBAN_TASK`) and for profiles that explicitly list the `kanban` toolset by name (the `all`/`*` wildcard does **not** enable it). Workers mark tasks done, block, heartbeat, comment, and create/link follow-up tasks; orchestrator profiles additionally get board-routing tools like list/unblock. `delegate_task` children are not Kanban run owners: their schema strips/disables this toolset and runtime guards reject direct board mutations, even if parent `HERMES_KANBAN_*` env vars are present. | | `memory` | `memory` | Persistent cross-session memory management. | | `project` | `project_create`, `project_list`, `project_switch` | Create and switch desktop [Projects](../user-guide/cli.md) (named, multi-folder workspaces). GUI / desktop sessions only. | | `safe` | `image_generate`, `vision_analyze`, `web_extract`, `web_search` (via `includes`) | Read-only research + media generation. No file writes, no terminal, no code execution. |