fix(security): remove model-controlled delegate ACP transport

Source: https://github.com/NousResearch/hermes-agent/pull/52346
Related prior work: https://github.com/NousResearch/hermes-agent/pull/39462
Related prior work: https://github.com/NousResearch/hermes-agent/pull/27426
Maintainer direction: https://github.com/NousResearch/hermes-agent/pull/52346#issuecomment-4854881612

Remove acp_command and acp_args from the model-facing delegate_task schema and
dispatch paths. Child agents can still use ACP subprocess transport when it
comes from trusted delegation config or parent inheritance, but a model tool
call can no longer choose the command or arguments that reach child
construction.

This is salvageable because the risky boundary is model control over child ACP
transport, not ACP itself. The patch follows the maintainer direction from the
source discussion by preserving trusted ACP configuration and prior integration
work while removing the untrusted tool-call fields from both top-level and
per-task delegate inputs.

Reproduced on main by passing acp_command through delegate_task and observing it
reach _build_child_agent. Verified after the fix that model dispatch strips the
hidden top-level fields and per-task hidden fields are ignored before child
construction.

Co-authored-by: Carlosian <claudlos@agentmail.to>
Co-authored-by: ssiweifnag <120658181+ssiweifnag@users.noreply.github.com>
Co-authored-by: nikshepsvn <23241247+nikshepsvn@users.noreply.github.com>
This commit is contained in:
Eugeniusz Gilewski 2026-07-02 01:43:21 +02:00 committed by Teknium
parent 1bcc52c14e
commit e4dbb67bf5
3 changed files with 84 additions and 216 deletions

View file

@ -1055,7 +1055,7 @@ def _build_child_agent(
override_base_url: Optional[str] = None,
override_api_key: Optional[str] = None,
override_api_mode: Optional[str] = None,
# ACP transport overrides — lets a non-ACP parent spawn ACP child agents
# ACP transport overrides from trusted delegation config.
override_acp_command: Optional[str] = None,
override_acp_args: Optional[List[str]] = None,
# Per-call role controlling whether the child can further delegate.
@ -1212,11 +1212,9 @@ def _build_child_agent(
effective_api_mode = None # force re-derivation from provider's defaults
else:
effective_api_mode = getattr(parent_agent, "api_mode", None)
# Defensive: validate override_acp_command exists on PATH before honoring
# it. Models occasionally pass acp_command="copilot" / "claude" / etc. in
# delegate_task tool calls despite the schema saying not to, which forces
# the subagent onto the copilot-acp transport below and crashes the
# gateway when the binary is missing (e.g. headless container deploys).
# Defensive: validate trusted delegation.command exists on PATH before
# honoring it. Stale config should not force a child onto the ACP transport
# and then fail at subprocess startup.
if override_acp_command:
import shutil as _shutil
@ -2346,8 +2344,6 @@ def delegate_task(
context: Optional[str] = None,
tasks: Optional[List[Dict[str, Any]]] = None,
max_iterations: Optional[int] = None,
acp_command: Optional[str] = None,
acp_args: Optional[List[str]] = None,
role: Optional[str] = None,
background: Optional[bool] = None,
parent_agent=None,
@ -2486,7 +2482,6 @@ def delegate_task(
children = []
try:
for i, t in enumerate(task_list):
task_acp_args = t.get("acp_args") if "acp_args" in t else None
# Per-task role beats top-level; normalise again so unknown
# per-task values warn and degrade to leaf uniformly.
effective_role = _normalize_role(t.get("role") or top_role)
@ -2505,14 +2500,8 @@ def delegate_task(
override_base_url=creds["base_url"],
override_api_key=creds["api_key"],
override_api_mode=creds["api_mode"],
override_acp_command=t.get("acp_command")
or acp_command
or creds.get("command"),
override_acp_args=(
task_acp_args
if task_acp_args is not None
else (acp_args if acp_args is not None else creds.get("args"))
),
override_acp_command=creds.get("command"),
override_acp_args=creds.get("args"),
role=effective_role,
)
# Override with correct parent tool names (before child construction mutated global)
@ -3292,30 +3281,6 @@ def _build_role_param_description() -> str:
)
# Known ACP-compatible CLIs that delegate_task can shell out to. Kept
# narrow on purpose: only the ones agent/copilot_acp_client.py and friends
# actually understand. Add new entries here when a new ACP CLI ships.
_KNOWN_ACP_BINARIES: tuple[str, ...] = ("copilot", "claude", "codex")
def _acp_binary_available() -> bool:
"""True iff at least one known ACP CLI is on PATH.
Used to gate inclusion of ``acp_command`` / ``acp_args`` in the
delegate_task schema. On headless hosts (Railway / Fly / Docker /
fresh VPS) without any of these binaries, exposing the fields invites
the model to hallucinate ``acp_command="copilot"`` from the schema's
description, which used to crash subagent runs and take the gateway
down. Pruning the fields from the schema removes the temptation.
Not cached: ``shutil.which`` is cheap and we want the schema to react
to mid-session installs without forcing a process restart.
"""
import shutil as _shutil
return any(_shutil.which(name) for name in _KNOWN_ACP_BINARIES)
def _build_dynamic_schema_overrides() -> dict:
"""Return per-call schema overrides reflecting current config.
@ -3333,24 +3298,6 @@ def _build_dynamic_schema_overrides() -> dict:
overrides_params["properties"]["tasks"]["description"] = _build_tasks_param_description()
overrides_params["properties"]["role"]["description"] = _build_role_param_description()
# Prune ACP overrides from the schema when no known ACP CLI is on PATH.
# The runtime guard in _build_child_agent remains as defense-in-depth for
# internal callers / tests / future code paths that skip the schema layer.
if not _acp_binary_available():
overrides_params["properties"].pop("acp_command", None)
overrides_params["properties"].pop("acp_args", None)
tasks_schema = dict(overrides_params["properties"].get("tasks", {}))
if "items" in tasks_schema:
items = dict(tasks_schema["items"])
if "properties" in items:
items["properties"] = {
k: v
for k, v in items["properties"].items()
if k not in ("acp_command", "acp_args")
}
tasks_schema["items"] = items
overrides_params["properties"]["tasks"] = tasks_schema
return {
"description": _build_top_level_description(),
"parameters": overrides_params,
@ -3401,19 +3348,6 @@ DELEGATE_TASK_SCHEMA = {
"type": "string",
"description": "Task-specific context",
},
"acp_command": {
"type": "string",
"description": (
"Per-task ACP command override (e.g. 'copilot'). "
"Overrides the top-level acp_command for this task only. "
"Do NOT set unless the user explicitly told you an ACP CLI is installed."
),
},
"acp_args": {
"type": "array",
"items": {"type": "string"},
"description": "Per-task ACP args override. Leave empty unless acp_command is set.",
},
"role": {
"type": "string",
"enum": ["leaf", "orchestrator"],
@ -3444,28 +3378,6 @@ DELEGATE_TASK_SCHEMA = {
"compatibility."
),
},
"acp_command": {
"type": "string",
"description": (
"Override ACP command for child agents (e.g. 'copilot'). "
"When set, children use ACP subprocess transport instead of inheriting "
"the parent's transport. Requires an ACP-compatible CLI "
"(currently GitHub Copilot CLI via 'copilot --acp --stdio'). "
"See agent/copilot_acp_client.py for the implementation. "
"IMPORTANT: Do NOT set this unless the user has explicitly told you "
"a specific ACP-compatible CLI is installed and configured. "
"Leave empty to use the parent's default transport (Hermes subagents)."
),
},
"acp_args": {
"type": "array",
"items": {"type": "string"},
"description": (
"Arguments for the ACP command (default: ['--acp', '--stdio']). "
"Only used when acp_command is set. "
"Leave empty unless acp_command is explicitly provided."
),
},
},
"required": [],
},
@ -3492,6 +3404,28 @@ def _model_background_value(args: dict, parent_agent=None) -> bool:
return not is_subagent
_MODEL_HIDDEN_TASK_FIELDS = {"acp_command", "acp_args"}
def _strip_model_hidden_task_fields(tasks: Any) -> Any:
if not isinstance(tasks, list):
return tasks
stripped_tasks = []
changed = False
for task in tasks:
if not isinstance(task, dict):
stripped_tasks.append(task)
continue
stripped = {
key: value
for key, value in task.items()
if key not in _MODEL_HIDDEN_TASK_FIELDS
}
changed = changed or len(stripped) != len(task)
stripped_tasks.append(stripped)
return stripped_tasks if changed else tasks
registry.register(
name="delegate_task",
toolset="delegation",
@ -3499,10 +3433,8 @@ registry.register(
handler=lambda args, **kw: delegate_task(
goal=args.get("goal"),
context=args.get("context"),
tasks=args.get("tasks"),
tasks=_strip_model_hidden_task_fields(args.get("tasks")),
max_iterations=args.get("max_iterations"),
acp_command=args.get("acp_command"),
acp_args=args.get("acp_args"),
role=args.get("role"),
background=_model_background_value(args, kw.get("parent_agent")),
parent_agent=kw.get("parent_agent"),