From cb6c47af08f2397424f027a01991a84dc99be3ee Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:22:08 -0700 Subject: [PATCH] feat(approvals): /deny relays denial reason to the agent (port nanoclaw#2832) (#54518) * feat(approvals): /deny relays denial reason to the agent Port from qwibitai/nanoclaw#2832 (reject with reason). Gateway /deny now accepts an optional trailing reason (/deny or /deny all ). 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). --- gateway/slash_commands.py | 32 ++++++++++-- hermes_cli/commands.py | 4 +- locales/af.yaml | 2 + locales/de.yaml | 2 + locales/en.yaml | 2 + locales/es.yaml | 2 + locales/fr.yaml | 2 + locales/ga.yaml | 2 + locales/hu.yaml | 2 + locales/it.yaml | 2 + locales/ja.yaml | 2 + locales/ko.yaml | 2 + locales/pt.yaml | 2 + locales/ru.yaml | 2 + locales/tr.yaml | 2 + locales/uk.yaml | 2 + locales/zh-hant.yaml | 2 + locales/zh.yaml | 2 + tests/gateway/test_approve_deny_commands.py | 55 +++++++++++++++++++++ tests/tools/test_approval_interrupt.py | 4 +- tools/approval.py | 52 +++++++++++++------ 21 files changed, 157 insertions(+), 22 deletions(-) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index cd1761bf862..0d7be83cf9e 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -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 `` (or ``/deny all ``) 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") diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 7a8775d2604..69c154ec6b0 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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=""), CommandDef("agents", "Show active agents and running tasks", "Session", diff --git a/locales/af.yaml b/locales/af.yaml index 18dd530586d..6dc9055def0 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -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." diff --git a/locales/de.yaml b/locales/de.yaml index fed360785e8..26acd3eb35f 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -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." diff --git a/locales/en.yaml b/locales/en.yaml index da2610be956..f970b057eca 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -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." diff --git a/locales/es.yaml b/locales/es.yaml index 518a4b793b9..15eedaa869e 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -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." diff --git a/locales/fr.yaml b/locales/fr.yaml index f6730ccdb7c..78935eed553 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -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." diff --git a/locales/ga.yaml b/locales/ga.yaml index 26ede56fd68..bad263ecfc0 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -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." diff --git a/locales/hu.yaml b/locales/hu.yaml index b98272b9f53..e3bbc6ea60b 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -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." diff --git a/locales/it.yaml b/locales/it.yaml index 5f0711e8468..f7474090536 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -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." diff --git a/locales/ja.yaml b/locales/ja.yaml index 02d4df8efd8..f11725d7ad8 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -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 モデルでのみ利用できます。" diff --git a/locales/ko.yaml b/locales/ko.yaml index 5404ec36ce9..aae3358bdc7 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -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 모델에서만 사용할 수 있습니다." diff --git a/locales/pt.yaml b/locales/pt.yaml index ee9b95bb9d1..2f8bcd03d46 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -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." diff --git a/locales/ru.yaml b/locales/ru.yaml index 3628f1e25b1..0450981fc2d 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -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." diff --git a/locales/tr.yaml b/locales/tr.yaml index 32f1d6b7218..2fd70cd439f 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -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." diff --git a/locales/uk.yaml b/locales/uk.yaml index af43c7e8d7f..5e0391c0dbb 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -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." diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 3f34fd2638c..8f2d5e59806 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -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 模型。" diff --git a/locales/zh.yaml b/locales/zh.yaml index 6183612b508..7defb22e1e0 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -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 模型。" diff --git a/tests/gateway/test_approve_deny_commands.py b/tests/gateway/test_approve_deny_commands.py index 6d50a31be2e..f5ac3b8c40b 100644 --- a/tests/gateway/test_approve_deny_commands.py +++ b/tests/gateway/test_approve_deny_commands.py @@ -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 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 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 diff --git a/tests/tools/test_approval_interrupt.py b/tests/tools/test_approval_interrupt.py index 832a503bc57..b991afd8088 100644 --- a/tests/tools/test_approval_interrupt.py +++ b/tests/tools/test_approval_interrupt.py @@ -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} diff --git a/tools/approval.py b/tools/approval.py index 53519b5c048..6ad4b59bdf2 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -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 ``) 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 ``). 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 ``) 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).