fix(approval): show CLI Dangerous Command prompt when ask-mode has no notifier

HERMES_EXEC_ASK (and gateway platform markers without a notify callback)
were short-circuiting interactive CLI into silent pending_approval, so
the Approve/Deny panel never appeared. Prefer the registered CLI callback
when present, and set HERMES_EXEC_ASK only in start_gateway so importing
gateway.run from CLI tools cannot poison the process.
This commit is contained in:
xxxigm
2026-08-14 17:49:03 +07:00
committed by kshitij
parent 1f8fdc7bd8
commit e37a0321eb
2 changed files with 111 additions and 55 deletions
+9 -2
View File
@@ -2399,8 +2399,10 @@ except Exception as _bootstrap_exc:
# Gateway runs in quiet mode - suppress debug output and use cwd directly (no temp dirs)
os.environ["HERMES_QUIET"] = "1"
# Enable interactive exec approval for dangerous commands on messaging platforms
os.environ["HERMES_EXEC_ASK"] = "1"
# HERMES_EXEC_ASK is set in start_gateway(), not at import time. Importing this
# module from CLI tools (e.g. send_message → _gateway_runner_ref) must not flip
# interactive CLI sessions into ask-mode, or Dangerous Command prompts become
# silent pending_approval with no Approve/Deny UI.
# Set terminal working directory for messaging platforms.
# config.yaml terminal.cwd is the canonical source (bridged to TERMINAL_CWD
@@ -28805,6 +28807,11 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
Useful for systemd services to avoid restart-loop deadlocks
when the previous process hasn't fully exited yet.
"""
# Enable interactive exec approval for dangerous commands on messaging
# platforms. Set here (not at module import) so incidental imports of
# gateway.run from CLI/tool code do not poison HERMES_EXEC_ASK.
os.environ["HERMES_EXEC_ASK"] = "1"
from hermes_cli.resource_limits import apply_nofile_soft_limit
apply_nofile_soft_limit()
+102 -53
View File
@@ -284,6 +284,38 @@ def _is_gateway_approval_context() -> bool:
return True
return bool(_get_session_platform())
def _resolve_cli_approval_callback(approval_callback=None):
"""Return an interactive CLI approval callback when one is available.
Prefers an explicitly passed callback, then the per-thread CLI callback
registered via ``tools.terminal_tool.set_approval_callback``.
"""
if approval_callback is not None:
return approval_callback
try:
from tools.terminal_tool import _get_approval_callback
return _get_approval_callback()
except Exception:
return None
def _should_fall_through_to_cli_approval(
*,
is_cli: bool,
approval_callback,
notify_cb,
) -> bool:
"""Prefer the classic CLI Dangerous Command panel over silent pending.
``HERMES_EXEC_ASK`` (and sometimes a session platform marker) can leak into
an interactive CLI process — most commonly via ``import gateway.run``, which
historically set ask-mode as a module-level side effect. Without a gateway
notify listener, the ask/gateway branch used to return ``pending_approval``
immediately and skip the CLI panel the user can actually answer.
"""
return bool(is_cli and approval_callback is not None and notify_cb is None)
# Sensitive write targets that should trigger approval even when referenced
# via shell expansions like $HOME or $HERMES_HOME, or by the resolved absolute
# active profile home path such as /home/hermes/.hermes/config.yaml. The
@@ -3293,12 +3325,7 @@ def _run_approval_gate(
if is_approved(session_key, pattern_key):
return {"approved": True, "message": None}
if approval_callback is None:
try:
from tools.terminal_tool import _get_approval_callback
approval_callback = _get_approval_callback()
except Exception:
approval_callback = None
approval_callback = _resolve_cli_approval_callback(approval_callback)
is_cli = _is_interactive_cli()
is_gateway = _is_gateway_approval_context()
@@ -3407,24 +3434,32 @@ def _run_approval_gate(
save_permanent_allowlist(_permanent_approved)
return {"approved": True, "message": None}
# No notify callback (e.g. API server without an attached chat):
# queue for /approve /deny review, agent sees approval_required.
submit_pending(session_key, {
"command": display_target,
"pattern_key": pattern_key,
"description": description,
})
return {
"approved": False,
"pattern_key": pattern_key,
"status": "approval_required",
"command": display_target,
"description": description,
"message": (
f"⚠️ This action is potentially dangerous ({description}). "
f"Asking the user for approval.\n\n**Target:**\n```\n{display_target}\n```"
),
}
# No notify callback: interactive CLI with a panel callback should
# still prompt locally instead of queuing a pending approval nobody
# can see (HERMES_EXEC_ASK / platform-marker leaks into CLI).
if not _should_fall_through_to_cli_approval(
is_cli=is_cli,
approval_callback=approval_callback,
notify_cb=notify_cb,
):
# No notify callback (e.g. API server without an attached chat):
# queue for /approve /deny review, agent sees approval_required.
submit_pending(session_key, {
"command": display_target,
"pattern_key": pattern_key,
"description": description,
})
return {
"approved": False,
"pattern_key": pattern_key,
"status": "approval_required",
"command": display_target,
"description": description,
"message": (
f"⚠️ This action is potentially dangerous ({description}). "
f"Asking the user for approval.\n\n**Target:**\n```\n{display_target}\n```"
),
}
_fire_approval_hook(
"pre_approval_request",
@@ -4036,6 +4071,7 @@ def check_all_command_guards(command: str, env_type: str,
if _command_matches_permanent_allowlist(command):
return {"approved": True, "message": None}
approval_callback = _resolve_cli_approval_callback(approval_callback)
is_cli = _is_interactive_cli()
is_gateway = _is_gateway_approval_context()
is_ask = env_var_enabled("HERMES_EXEC_ASK")
@@ -4419,35 +4455,44 @@ def check_all_command_guards(command: str, env_type: str,
"user_approved": True, "description": combined_desc}
# Fallback: no gateway callback registered (e.g. cron, batch).
# Return approval_required for backward compat. Redact secrets in the
# user-facing copy — the raw `command` is preserved for execution and
# the allowlist keys off pattern_key, so redaction is display-only.
from agent.redact import redact_sensitive_text
_disp_command = redact_sensitive_text(command)
_disp_combined_desc = redact_sensitive_text(combined_desc)
pending_data = {
"command": _disp_command,
"pattern_key": primary_key,
"pattern_keys": all_keys,
"description": _disp_combined_desc,
}
if smart_denied_for_owner:
pending_data.update(smart_denied=True, allow_permanent=False)
submit_pending(session_key, pending_data)
result = {
"approved": False,
"pattern_key": primary_key,
"status": "pending_approval",
"approval_pending": True,
"command": _disp_command,
"description": _disp_combined_desc,
"message": (
f"⚠️ {_disp_combined_desc}. Asking the user for approval.\n\n**Command:**\n```\n{_disp_command}\n```"
),
}
if smart_denied_for_owner:
result.update(smart_denied=True, allow_permanent=False)
return result
# Interactive CLI with a Dangerous Command callback should still
# paint the local panel — ask-mode often leaks into CLI via
# importing gateway.run, and returning pending_approval here makes
# the agent look "auto-blocked" with no Approve/Deny UI.
if not _should_fall_through_to_cli_approval(
is_cli=is_cli,
approval_callback=approval_callback,
notify_cb=notify_cb,
):
# Return approval_required for backward compat. Redact secrets in the
# user-facing copy — the raw `command` is preserved for execution and
# the allowlist keys off pattern_key, so redaction is display-only.
from agent.redact import redact_sensitive_text
_disp_command = redact_sensitive_text(command)
_disp_combined_desc = redact_sensitive_text(combined_desc)
pending_data = {
"command": _disp_command,
"pattern_key": primary_key,
"pattern_keys": all_keys,
"description": _disp_combined_desc,
}
if smart_denied_for_owner:
pending_data.update(smart_denied=True, allow_permanent=False)
submit_pending(session_key, pending_data)
result = {
"approved": False,
"pattern_key": primary_key,
"status": "pending_approval",
"approval_pending": True,
"command": _disp_command,
"description": _disp_combined_desc,
"message": (
f"⚠️ {_disp_combined_desc}. Asking the user for approval.\n\n**Command:**\n```\n{_disp_command}\n```"
),
}
if smart_denied_for_owner:
result.update(smart_denied=True, allow_permanent=False)
return result
# CLI interactive: single combined prompt
# Hide [a]lways when no persistable (non-tirith) warning is present
@@ -4602,6 +4647,10 @@ def check_execute_code_guard(code: str, env_type: str,
# (context now propagates into the RPC thread, #33057); a whole-script
# prompt would fire on every execute_code call.
# * Local non-interactive non-gateway: documented limitation above.
# Ask-mode (HERMES_EXEC_ASK) still takes this path even when INTERACTIVE
# is also set — that combination is how gateway/smart tests and messaging
# ask-mode drive whole-script approval. Terminal-command CLI leaks are
# handled in check_all_command_guards via the CLI callback fall-through.
if not is_gateway and not is_ask:
return {"approved": True, "message": None}