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:
+9
-2
@@ -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
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user