feat(approvals): /deny <reason> relays denial reason to the agent (port nanoclaw#2832) (#54518)

* feat(approvals): /deny <reason> relays denial reason to the agent

Port from qwibitai/nanoclaw#2832 (reject with reason).

Gateway /deny now accepts an optional trailing reason (/deny <reason>
or /deny all <reason>). The reason rides on the per-session approval
entry through resolve_gateway_approval -> _await_gateway_decision and is
appended to the BLOCKED tool result the agent receives, so a declined
agent can adapt instead of only hearing 'denied'.

Adapted to hermes-agent's synchronous single-command /deny model: no DB
state, no second-message capture step, no migration. Reason is capped at
280 chars and threaded through both the terminal-command guard and the
execute_code guard. Plain /deny and the approve paths are unchanged.

- tools/approval.py: _ApprovalEntry.reason; resolve_gateway_approval gains
  optional reason; _await_gateway_decision returns it; both gateway BLOCKED
  messages include it
- gateway/slash_commands.py: parse leading 'all' + trailing reason
- locales/en.yaml: deny.denied_reason_{singular,plural}
- hermes_cli/commands.py: /deny args_hint '[all] [reason]'
- tests: 3 new (with-reason, all+reason, plain-deny regression)

* fix(ci): localize deny-reason keys across all locales + update interrupt-path assertions

CI surfaced two enforced invariants broken by the deny-with-reason change:
- test_i18n catalog-parity requires every locale to carry the same keys as
  en.yaml with matching placeholders. Added deny.denied_reason_singular/plural
  (with {count}/{reason}) to all 15 non-English locales.
- test_approval_interrupt asserts the exact dict from _await_gateway_decision,
  which now carries a 'reason' key (None on the interrupt/timeout paths).
This commit is contained in:
Teknium 2026-07-05 02:22:08 -07:00 committed by GitHub
parent 9767e19b60
commit cb6c47af08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 157 additions and 22 deletions

View file

@ -4360,6 +4360,9 @@ class GatewaySlashCommandsMixin:
a definitive BLOCKED message, same as the CLI deny flow.
``/deny`` denies the oldest; ``/deny all`` denies everything.
``/deny <reason>`` (or ``/deny all <reason>``) attaches a one-line
reason that is relayed back to the agent so it can adapt instead of
only hearing "denied". Ported from qwibitai/nanoclaw#2832.
"""
source = event.source
session_key = self._session_key_for_source(source)
@ -4374,10 +4377,24 @@ class GatewaySlashCommandsMixin:
return t("gateway.deny.stale")
return t("gateway.deny.no_pending")
args = event.get_command_args().strip().lower()
resolve_all = "all" in args
# Parse args: a leading "all" token denies every pending command;
# anything after it (or the whole arg string when "all" is absent) is
# captured verbatim as the optional deny reason relayed to the agent.
raw_args = event.get_command_args().strip()
tokens = raw_args.split()
resolve_all = bool(tokens) and tokens[0].lower() == "all"
if resolve_all:
reason = raw_args[len(tokens[0]):].strip()
else:
reason = raw_args
# Cap to a sane one-liner; the agent only needs a short hint.
if reason:
reason = reason[:280].strip()
count = resolve_gateway_approval(session_key, "deny", resolve_all=resolve_all)
count = resolve_gateway_approval(
session_key, "deny", resolve_all=resolve_all,
reason=reason or None,
)
if not count:
return t("gateway.deny.no_pending")
@ -4386,7 +4403,14 @@ class GatewaySlashCommandsMixin:
if _adapter:
_adapter.resume_typing_for_chat(source.chat_id)
logger.info("User denied %d dangerous command(s) via /deny", count)
logger.info(
"User denied %d dangerous command(s) via /deny%s",
count, " (with reason)" if reason else "",
)
if reason:
if count > 1:
return t("gateway.deny.denied_reason_plural", count=count, reason=reason)
return t("gateway.deny.denied_reason_singular", reason=reason)
if count > 1:
return t("gateway.deny.denied_plural", count=count)
return t("gateway.deny.denied_singular")

View file

@ -97,8 +97,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("stop", "Kill all running background processes", "Session"),
CommandDef("approve", "Approve a pending dangerous command", "Session",
gateway_only=True, args_hint="[session|always]"),
CommandDef("deny", "Deny a pending dangerous command", "Session",
gateway_only=True),
CommandDef("deny", "Deny a pending dangerous command (optionally with a reason)", "Session",
gateway_only=True, args_hint="[all] [reason]"),
CommandDef("background", "Run a prompt in the background", "Session",
aliases=("bg", "btw"), args_hint="<prompt>"),
CommandDef("agents", "Show active agents and running tasks", "Session",

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "Geen hangende opdrag om te weier nie."
denied_singular: "❌ Opdrag geweier."
denied_plural: "❌ Opdragte geweier ({count} opdragte)."
denied_reason_singular: "❌ Opdrag geweier. Rede aan die agent oorgedra: \"{reason}\""
denied_reason_plural: "❌ Opdragte geweier ({count} opdragte). Rede aan die agent oorgedra: \"{reason}\""
fast:
not_supported: "⚡ /fast is slegs beskikbaar vir OpenAI-modelle wat Priority Processing ondersteun."

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "Kein ausstehender Befehl zum Ablehnen."
denied_singular: "❌ Befehl abgelehnt."
denied_plural: "❌ Befehle abgelehnt ({count} Befehle)."
denied_reason_singular: "❌ Befehl abgelehnt. Grund an den Agenten weitergeleitet: \"{reason}\""
denied_reason_plural: "❌ Befehle abgelehnt ({count} Befehle). Grund an den Agenten weitergeleitet: \"{reason}\""
fast:
not_supported: "⚡ /fast ist nur für OpenAI-Modelle mit Priority Processing verfügbar."

View file

@ -122,6 +122,8 @@ gateway:
no_pending: "No pending command to deny."
denied_singular: "❌ Command denied."
denied_plural: "❌ Commands denied ({count} commands)."
denied_reason_singular: "❌ Command denied. Reason relayed to the agent: \"{reason}\""
denied_reason_plural: "❌ Commands denied ({count} commands). Reason relayed to the agent: \"{reason}\""
fast:
not_supported: "⚡ /fast is only available for OpenAI models that support Priority Processing."

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "No hay ningún comando pendiente que denegar."
denied_singular: "❌ Comando denegado."
denied_plural: "❌ Comandos denegados ({count} comandos)."
denied_reason_singular: "❌ Comando denegado. Motivo transmitido al agente: \"{reason}\""
denied_reason_plural: "❌ Comandos denegados ({count} comandos). Motivo transmitido al agente: \"{reason}\""
fast:
not_supported: "⚡ /fast solo está disponible para modelos de OpenAI que admiten Priority Processing."

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "Aucune commande en attente de refus."
denied_singular: "❌ Commande refusée."
denied_plural: "❌ Commandes refusées ({count} commandes)."
denied_reason_singular: "❌ Commande refusée. Raison transmise à l'agent: \"{reason}\""
denied_reason_plural: "❌ Commandes refusées ({count} commandes). Raison transmise à l'agent: \"{reason}\""
fast:
not_supported: "⚡ /fast n'est disponible que pour les modèles OpenAI qui prennent en charge Priority Processing."

View file

@ -111,6 +111,8 @@ gateway:
no_pending: "Níl aon ordú ag fanacht le diúltú."
denied_singular: "❌ Ordú diúltaithe."
denied_plural: "❌ Orduithe diúltaithe ({count} ordú)."
denied_reason_singular: "❌ Ordú diúltaithe. Cúis curtha ar aghaidh chuig an ngníomhaire: \"{reason}\""
denied_reason_plural: "❌ Orduithe diúltaithe ({count} ordú). Cúis curtha ar aghaidh chuig an ngníomhaire: \"{reason}\""
fast:
not_supported: "⚡ Tá /fast ar fáil amháin do shamhlacha OpenAI a thacaíonn le Priority Processing."

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "Nincs elutasítható függőben lévő parancs."
denied_singular: "❌ Parancs elutasítva."
denied_plural: "❌ Parancsok elutasítva ({count} parancs)."
denied_reason_singular: "❌ Parancs elutasítva. Indok továbbítva az ügynöknek: \"{reason}\""
denied_reason_plural: "❌ Parancsok elutasítva ({count} parancs). Indok továbbítva az ügynöknek: \"{reason}\""
fast:
not_supported: "⚡ A /fast csak olyan OpenAI modelleknél érhető el, amelyek támogatják a Priority Processinget."

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "Nessun comando in attesa da negare."
denied_singular: "❌ Comando negato."
denied_plural: "❌ Comandi negati ({count} comandi)."
denied_reason_singular: "❌ Comando negato. Motivo inoltrato all'agente: \"{reason}\""
denied_reason_plural: "❌ Comandi negati ({count} comandi). Motivo inoltrato all'agente: \"{reason}\""
fast:
not_supported: "⚡ /fast è disponibile solo per i modelli OpenAI che supportano Priority Processing."

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "拒否待ちのコマンドはありません。"
denied_singular: "❌ コマンドを拒否しました。"
denied_plural: "❌ コマンドを拒否しました ({count} 件)。"
denied_reason_singular: "❌ コマンドを拒否しました。 理由をエージェントに伝達しました: \"{reason}\""
denied_reason_plural: "❌ コマンドを拒否しました ({count} 件)。 理由をエージェントに伝達しました: \"{reason}\""
fast:
not_supported: "⚡ /fast は Priority Processing をサポートする OpenAI モデルでのみ利用できます。"

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "거부 대기 중인 명령이 없습니다."
denied_singular: "❌ 명령이 거부되었습니다."
denied_plural: "❌ 명령이 거부되었습니다 ({count}개)."
denied_reason_singular: "❌ 명령이 거부되었습니다. 사유를 에이전트에 전달함: \"{reason}\""
denied_reason_plural: "❌ 명령이 거부되었습니다 ({count}개). 사유를 에이전트에 전달함: \"{reason}\""
fast:
not_supported: "⚡ /fast는 Priority Processing을 지원하는 OpenAI 모델에서만 사용할 수 있습니다."

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "Não há nenhum comando pendente para negar."
denied_singular: "❌ Comando negado."
denied_plural: "❌ Comandos negados ({count} comandos)."
denied_reason_singular: "❌ Comando negado. Motivo repassado ao agente: \"{reason}\""
denied_reason_plural: "❌ Comandos negados ({count} comandos). Motivo repassado ao agente: \"{reason}\""
fast:
not_supported: "⚡ /fast só está disponível para modelos da OpenAI que suportam Priority Processing."

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "Нет команды для отклонения."
denied_singular: "❌ Команда отклонена."
denied_plural: "❌ Команды отклонены ({count} команд)."
denied_reason_singular: "❌ Команда отклонена. Причина передана агенту: \"{reason}\""
denied_reason_plural: "❌ Команды отклонены ({count} команд). Причина передана агенту: \"{reason}\""
fast:
not_supported: "⚡ /fast доступен только для моделей OpenAI, поддерживающих Priority Processing."

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "Reddedilecek bekleyen komut yok."
denied_singular: "❌ Komut reddedildi."
denied_plural: "❌ Komutlar reddedildi ({count} komut)."
denied_reason_singular: "❌ Komut reddedildi. Gerekçe ajana iletildi: \"{reason}\""
denied_reason_plural: "❌ Komutlar reddedildi ({count} komut). Gerekçe ajana iletildi: \"{reason}\""
fast:
not_supported: "⚡ /fast yalnızca Priority Processing destekleyen OpenAI modellerinde kullanılabilir."

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "Немає команди для відхилення."
denied_singular: "❌ Команду відхилено."
denied_plural: "❌ Команди відхилено ({count} команд)."
denied_reason_singular: "❌ Команду відхилено. Причину передано агентові: \"{reason}\""
denied_reason_plural: "❌ Команди відхилено ({count} команд). Причину передано агентові: \"{reason}\""
fast:
not_supported: "⚡ /fast доступний лише для моделей OpenAI, які підтримують Priority Processing."

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "沒有待拒絕的指令。"
denied_singular: "❌ 指令已拒絕。"
denied_plural: "❌ 指令已拒絕({count} 條指令)。"
denied_reason_singular: "❌ 指令已拒絕。 已將原因轉達給代理: \"{reason}\""
denied_reason_plural: "❌ 指令已拒絕({count} 條指令)。 已將原因轉達給代理: \"{reason}\""
fast:
not_supported: "⚡ /fast 僅適用於支援 Priority Processing 的 OpenAI 模型。"

View file

@ -107,6 +107,8 @@ gateway:
no_pending: "没有待拒绝的命令。"
denied_singular: "❌ 命令已拒绝。"
denied_plural: "❌ 命令已拒绝({count} 条命令)。"
denied_reason_singular: "❌ 命令已拒绝。 已将原因转达给代理: \"{reason}\""
denied_reason_plural: "❌ 命令已拒绝({count} 条命令)。 已将原因转达给代理: \"{reason}\""
fast:
not_supported: "⚡ /fast 仅适用于支持优先处理Priority Processing的 OpenAI 模型。"

View file

@ -324,6 +324,61 @@ class TestDenyCommand:
result = await runner._handle_deny_command(_make_event("/deny"))
assert "No pending command" in result
@pytest.mark.asyncio
async def test_deny_with_reason_attaches_reason(self):
"""/deny <reason> attaches the reason to the resolved entry."""
from tools.approval import _ApprovalEntry, _gateway_queues
runner = _make_runner()
source = _make_source()
session_key = runner._session_key_for_source(source)
entry = _ApprovalEntry({"command": "test"})
_gateway_queues[session_key] = [entry]
result = await runner._handle_deny_command(
_make_event("/deny that path is still in use")
)
assert entry.result == "deny"
assert entry.reason == "that path is still in use"
assert "that path is still in use" in result
@pytest.mark.asyncio
async def test_deny_all_with_reason(self):
"""/deny all <reason> denies everything and relays one reason."""
from tools.approval import _ApprovalEntry, _gateway_queues
runner = _make_runner()
source = _make_source()
session_key = runner._session_key_for_source(source)
e1 = _ApprovalEntry({"command": "cmd1"})
e2 = _ApprovalEntry({"command": "cmd2"})
_gateway_queues[session_key] = [e1, e2]
result = await runner._handle_deny_command(
_make_event("/deny all wrong directory")
)
assert "2 commands" in result
assert all(e.result == "deny" for e in [e1, e2])
assert all(e.reason == "wrong directory" for e in [e1, e2])
@pytest.mark.asyncio
async def test_deny_plain_has_no_reason(self):
"""A bare /deny leaves the reason unset (regression guard)."""
from tools.approval import _ApprovalEntry, _gateway_queues
runner = _make_runner()
source = _make_source()
session_key = runner._session_key_for_source(source)
entry = _ApprovalEntry({"command": "test"})
_gateway_queues[session_key] = [entry]
await runner._handle_deny_command(_make_event("/deny"))
assert entry.result == "deny"
assert entry.reason is None
# ------------------------------------------------------------------
# Bare "yes" must NOT trigger approval

View file

@ -113,7 +113,7 @@ class TestApprovalInterrupt:
elapsed = time.monotonic() - start
assert not t.is_alive(), "approval wait did not return after interrupt"
assert result_holder["result"] == {"resolved": True, "choice": "deny"}
assert result_holder["result"] == {"resolved": True, "choice": "deny", "reason": None}
# Must be far below the 300s timeout — the interrupt, not the deadline,
# is what released the wait.
assert elapsed < 10, f"interrupt path too slow ({elapsed:.1f}s)"
@ -157,4 +157,4 @@ class TestApprovalInterrupt:
t.join(timeout=10)
assert not t.is_alive()
# Timed out (no resolution) because the foreign interrupt was ignored.
assert result_holder["result"] == {"resolved": False, "choice": None}
assert result_holder["result"] == {"resolved": False, "choice": None, "reason": None}

View file

@ -1370,12 +1370,16 @@ _permanent_approved: set = set()
class _ApprovalEntry:
"""One pending dangerous-command approval inside a gateway session."""
__slots__ = ("event", "data", "result")
__slots__ = ("event", "data", "result", "reason")
def __init__(self, data: dict):
self.event = threading.Event()
self.data = data # command, description, pattern_keys, …
self.result: Optional[str] = None # "once"|"session"|"always"|"deny"
# Optional free-text reason supplied with an explicit deny
# (``/deny <reason>``) so the agent can adapt instead of only
# hearing "denied". Ported from qwibitai/nanoclaw#2832.
self.reason: Optional[str] = None
_gateway_queues: dict[str, list] = {} # session_key → [_ApprovalEntry, …]
@ -1408,7 +1412,8 @@ def unregister_gateway_notify(session_key: str) -> None:
def resolve_gateway_approval(session_key: str, choice: str,
resolve_all: bool = False) -> int:
resolve_all: bool = False,
reason: Optional[str] = None) -> int:
"""Called by the gateway's /approve or /deny handler to unblock
waiting agent thread(s).
@ -1416,6 +1421,10 @@ def resolve_gateway_approval(session_key: str, choice: str,
resolved at once (``/approve all``). Otherwise only the oldest one
is resolved (FIFO).
*reason* is an optional free-text explanation attached to an explicit
deny (``/deny <reason>``). It is relayed back to the agent in the
BLOCKED message so it can adapt instead of only hearing "denied".
Returns the number of approvals resolved (0 means nothing was pending).
"""
with _lock:
@ -1432,6 +1441,8 @@ def resolve_gateway_approval(session_key: str, choice: str,
for entry in targets:
entry.result = choice
if reason:
entry.reason = reason
entry.event.set()
return len(targets)
@ -2206,7 +2217,7 @@ def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict,
surface=surface,
choice=_outcome,
)
return {"resolved": resolved, "choice": choice}
return {"resolved": resolved, "choice": choice, "reason": entry.reason}
def check_all_command_guards(command: str, env_type: str,
@ -2480,6 +2491,7 @@ def check_all_command_guards(command: str, env_type: str,
}
resolved = decision["resolved"]
choice = decision["choice"]
deny_reason = decision.get("reason")
if not resolved or choice is None or choice == "deny":
# Consent contract: silence is NOT consent, and an explicit
@ -2495,21 +2507,28 @@ def check_all_command_guards(command: str, env_type: str,
reason = "denied by user"
timeout_addendum = ""
outcome = "denied"
# An explicit deny may carry a free-text reason
# (``/deny <reason>``) so the agent can adapt rather than only
# hearing "denied". Relayed verbatim; generic attribution.
reason_addendum = ""
if outcome == "denied" and deny_reason:
reason_addendum = f' Reason given by the user: "{deny_reason}".'
return {
"approved": False,
"message": (
f"BLOCKED: Command {reason}. The user has NOT consented "
f"to this action. Do NOT retry this command, do NOT "
f"rephrase it, and do NOT attempt the same outcome via "
f"a different command. Stop the current workflow and "
f"wait for the user to respond before taking any "
f"further destructive or irreversible action."
f"{timeout_addendum}"
f"BLOCKED: Command {reason}.{reason_addendum} The user "
f"has NOT consented to this action. Do NOT retry this "
f"command, do NOT rephrase it, and do NOT attempt the "
f"same outcome via a different command. Stop the "
f"current workflow and wait for the user to respond "
f"before taking any further destructive or "
f"irreversible action.{timeout_addendum}"
),
"pattern_key": primary_key,
"description": combined_desc,
"outcome": outcome,
"user_consent": False,
"deny_reason": deny_reason,
}
# User approved — persist based on scope (same logic as CLI)
@ -2773,22 +2792,27 @@ def check_execute_code_guard(code: str, env_type: str,
resolved = decision["resolved"]
choice = decision["choice"]
deny_reason = decision.get("reason")
if not resolved or choice is None or choice == "deny":
reason = "timed out without user response" if not resolved else "denied by user"
addendum = " Silence is not consent." if not resolved else ""
reason_addendum = ""
if resolved and choice == "deny" and deny_reason:
reason_addendum = f' Reason given by the user: "{deny_reason}".'
return {
"approved": False,
"message": (
f"BLOCKED: execute_code script {reason}. The user has NOT "
f"consented to running this code. Do NOT retry, do NOT rephrase "
f"the script, and do NOT attempt the same outcome via a "
f"different tool.{addendum}"
f"BLOCKED: execute_code script {reason}.{reason_addendum} The "
f"user has NOT consented to running this code. Do NOT retry, "
f"do NOT rephrase the script, and do NOT attempt the same "
f"outcome via a different tool.{addendum}"
),
"pattern_key": pattern_key,
"description": description,
"outcome": "timeout" if not resolved else "denied",
"user_consent": False,
"deny_reason": deny_reason,
}
# Approved — persist based on scope (same logic as check_all_command_guards).