Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3888,6 +3888,9 @@ async def _handle_deny_command(self, event: MessageEvent) -> str:
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)
Expand All @@ -3902,10 +3905,24 @@ async def _handle_deny_command(self, event: MessageEvent) -> str:
return t("gateway.deny.stale")
return t("gateway.deny.no_pending")

args = event.get_command_args().strip().lower()
resolve_all = "all" in args

count = resolve_gateway_approval(session_key, "deny", resolve_all=resolve_all)
# 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,
reason=reason or None,
)
if not count:
return t("gateway.deny.no_pending")

Expand All @@ -3914,7 +3931,14 @@ async def _handle_deny_command(self, event: MessageEvent) -> str:
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")
Expand Down
4 changes: 2 additions & 2 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ class 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",
Expand Down
2 changes: 2 additions & 0 deletions locales/af.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/es.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/fr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/ga.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/hu.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/it.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/ja.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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 モデルでのみ利用できます。"
Expand Down
2 changes: 2 additions & 0 deletions locales/ko.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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 모델에서만 사용할 수 있습니다."
Expand Down
2 changes: 2 additions & 0 deletions locales/pt.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/ru.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/tr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/uk.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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."
Expand Down
2 changes: 2 additions & 0 deletions locales/zh-hant.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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 模型。"
Expand Down
2 changes: 2 additions & 0 deletions locales/zh.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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 模型。"
Expand Down
55 changes: 55 additions & 0 deletions tests/gateway/test_approve_deny_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,61 @@ async def test_deny_no_pending(self):
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
Expand Down
4 changes: 2 additions & 2 deletions tests/tools/test_approval_interrupt.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def _worker():
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)"
Expand Down Expand Up @@ -157,4 +157,4 @@ def _worker():
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}
Loading
Loading