Compare commits

...

1 Commits

Author SHA1 Message Date
teknium1
7a36623652 feat(discord): optional admin-only gate for exec-approval buttons
Add an opt-in toggle (require_admin_for_exec_approval, default false) that
restricts who can click Approve/Deny on a dangerous-command prompt to admins
listed in allow_admin_from. Off by default, so the v0.16-restored user-scope
behavior is unchanged. When on, the clicker must pass the normal admission
check AND be an admin; fails closed (logged) when no admins are configured.
Only ExecApprovalView is gated — model picker / clarify / update-prompt stay
user-scope.
2026-06-24 00:18:54 -07:00
3 changed files with 203 additions and 3 deletions

View File

@@ -4723,10 +4723,15 @@ class DiscordAdapter(BasePlatformAdapter):
)
embed.add_field(name="Reason", value=description, inline=False)
require_admin, admin_user_ids = _resolve_exec_approval_admin_gate(
getattr(self.config, "extra", None)
)
view = ExecApprovalView(
session_key=session_key,
allowed_user_ids=self._allowed_user_ids,
allowed_role_ids=self._allowed_role_ids,
require_admin=require_admin,
admin_user_ids=admin_user_ids,
)
msg = await channel.send(embed=embed, view=view)
@@ -5782,6 +5787,42 @@ def _component_check_auth(
return False
def _resolve_exec_approval_admin_gate(
config_extra: Optional[dict],
) -> Tuple[bool, set]:
"""Resolve the exec-approval admin gate from a platform's ``extra`` config.
Returns ``(require_admin, admin_user_ids)``.
Behavior (default-OFF, opt-in):
- ``require_admin_for_exec_approval`` absent/false -> ``(False, set())``;
exec-approval buttons stay user-scope (any admitted user can click),
which is the v0.16-restored behavior. This is the default so existing
installs are unaffected.
- toggle true -> ``(True, <admin ids from allow_admin_from>)``. Only
users in ``allow_admin_from`` (the same key the slash-access split
uses) may click exec-approval buttons.
The admin id list reuses ``slash_access._coerce_id_list`` so a string,
list, or scalar all normalize identically to the slash-command gate.
Misconfiguration (toggle on, no admins listed) returns ``(True, set())``
-> the view fails closed and logs once, rather than silently locking the
owner out without explanation.
"""
extra = config_extra if isinstance(config_extra, dict) else {}
raw_toggle = extra.get("require_admin_for_exec_approval", False)
require_admin = str(raw_toggle).strip().lower() in {"true", "1", "yes"}
if not require_admin:
return (False, set())
try:
from gateway.slash_access import _coerce_id_list
admin_ids = set(_coerce_id_list(extra.get("allow_admin_from")))
except Exception:
admin_ids = set()
return (True, admin_ids)
def _define_discord_view_classes() -> None:
"""Register Discord UI view classes as module globals.
@@ -5809,18 +5850,54 @@ def _define_discord_view_classes() -> None:
session_key: str,
allowed_user_ids: set,
allowed_role_ids: Optional[set] = None,
require_admin: bool = False,
admin_user_ids: Optional[set] = None,
):
super().__init__(timeout=300) # 5-minute timeout
self.session_key = session_key
self.allowed_user_ids = allowed_user_ids
self.allowed_role_ids = allowed_role_ids or set()
# Opt-in admin gate for exec approval (default off → user-scope,
# the v0.16-restored behavior). When on, the clicker must be in
# ``admin_user_ids`` on top of passing the base admission check.
self.require_admin = require_admin
self.admin_user_ids = {
str(a).strip() for a in (admin_user_ids or set()) if str(a).strip()
}
self.resolved = False
def _check_auth(self, interaction: discord.Interaction) -> bool:
"""Verify the user clicking is authorized."""
return _component_check_auth(
"""Verify the user clicking is authorized.
Base admission (allowlist / role / pairing) is always required.
When ``require_admin`` is on, the clicker must ALSO be an admin —
approving a dangerous command is gated to operators, while plain
chat and the lower-stakes component views stay user-scope. The
gate fails closed: if it's on but no admins are configured, nobody
can approve (logged once so the misconfiguration is visible).
"""
if not _component_check_auth(
interaction, self.allowed_user_ids, self.allowed_role_ids,
)
):
return False
if not self.require_admin:
return True
user = getattr(interaction, "user", None)
try:
uid = str(getattr(user, "id", "") or "")
except Exception:
uid = ""
if uid and uid in self.admin_user_ids:
return True
if not self.admin_user_ids:
logger.warning(
"[Discord] require_admin_for_exec_approval is enabled but "
"no admins are configured (allow_admin_from is empty) — "
"exec approval buttons are disabled for everyone. Add "
"admin user IDs under the discord platform's "
"allow_admin_from, or disable the toggle."
)
return False
async def _resolve(
self, interaction: discord.Interaction, choice: str,

View File

@@ -25,6 +25,7 @@ from plugins.platforms.discord.adapter import ( # noqa: E402
SlashConfirmView,
UpdatePromptView,
_component_check_auth,
_resolve_exec_approval_admin_gate,
)
@@ -349,3 +350,105 @@ def test_component_check_pairing_import_error_graceful(monkeypatch):
with patch("gateway.pairing.PairingStore", side_effect=ImportError("simulated")):
interaction = _interaction(11111)
assert _component_check_auth(interaction, set(), set()) is False
# ---------------------------------------------------------------------------
# Opt-in admin gate for exec-approval buttons (feat/discord-admin-exec-approval).
# Default OFF: any admitted user can approve (the v0.16-restored behavior).
# When `require_admin_for_exec_approval` is true, the clicker must ALSO be in
# `allow_admin_from`. Fails closed (logged) when the toggle is on but no
# admins are configured. Only ExecApprovalView is gated — other views stay
# user-scope.
# ---------------------------------------------------------------------------
def test_admin_gate_resolver_default_off():
"""Absent / falsey toggle -> gate disabled, no admin set."""
assert _resolve_exec_approval_admin_gate(None) == (False, set())
assert _resolve_exec_approval_admin_gate({}) == (False, set())
assert _resolve_exec_approval_admin_gate(
{"require_admin_for_exec_approval": False}
) == (False, set())
def test_admin_gate_resolver_on_parses_admins():
"""Toggle true -> gate enabled, admins coerced from allow_admin_from."""
require_admin, admins = _resolve_exec_approval_admin_gate(
{"require_admin_for_exec_approval": True, "allow_admin_from": "111, 222"}
)
assert require_admin is True
assert admins == {"111", "222"}
# list form normalizes identically
_, admins_list = _resolve_exec_approval_admin_gate(
{"require_admin_for_exec_approval": "true", "allow_admin_from": [111, 222]}
)
assert admins_list == {"111", "222"}
def test_exec_view_gate_off_allows_admitted_user():
"""Gate off: an allowlisted (admitted) non-admin can approve, as today."""
view = ExecApprovalView(session_key="s", allowed_user_ids={"11111"})
assert view._check_auth(_interaction(11111)) is True
def test_exec_view_gate_on_admin_authorized():
"""Gate on: admitted user who is also an admin is authorized."""
view = ExecApprovalView(
session_key="s",
allowed_user_ids={"11111"},
require_admin=True,
admin_user_ids={"11111"},
)
assert view._check_auth(_interaction(11111)) is True
def test_exec_view_gate_on_non_admin_rejected():
"""Gate on: admitted user who is NOT an admin is rejected at the button."""
view = ExecApprovalView(
session_key="s",
allowed_user_ids={"11111", "22222"},
require_admin=True,
admin_user_ids={"11111"},
)
# 22222 is admitted (in allowlist) but not an admin -> rejected.
assert view._check_auth(_interaction(22222)) is False
def test_exec_view_gate_on_no_admins_fails_closed(caplog):
"""Gate on but no admins configured -> nobody approves, logged once."""
import logging
view = ExecApprovalView(
session_key="s",
allowed_user_ids={"11111"},
require_admin=True,
admin_user_ids=set(),
)
with caplog.at_level(logging.WARNING):
assert view._check_auth(_interaction(11111)) is False
assert any(
"require_admin_for_exec_approval" in r.message for r in caplog.records
)
def test_exec_view_gate_on_non_admitted_user_rejected_before_admin_check():
"""Base admission still required: a non-admitted user is rejected even
if they somehow appear in the admin set (admission is the first gate)."""
view = ExecApprovalView(
session_key="s",
allowed_user_ids=set(), # nobody admitted, no pairing (autouse mock False)
require_admin=True,
admin_user_ids={"33333"},
)
assert view._check_auth(_interaction(33333)) is False
def test_other_views_not_admin_gated():
"""Lower-stakes views never take the admin gate — they stay user-scope."""
# SlashConfirmView/ModelPickerView/etc. construct without require_admin and
# delegate straight to _component_check_auth.
sc = SlashConfirmView(
session_key="s", confirm_id="c", allowed_user_ids={"11111"}
)
assert sc._check_auth(_interaction(11111)) is True

View File

@@ -567,6 +567,26 @@ gateway:
Use `/whoami` to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run.
### Restricting exec-approval buttons to admins
By default, any user allowed to talk to the bot — including users paired via `hermes pairing approve` — can click the **Approve / Deny** buttons on a dangerous-command prompt. This mirrors plain-chat admission and is the historical behavior. To restrict *approving dangerous commands* to admins only, set `require_admin_for_exec_approval` in the Discord platform's `extra` block:
```yaml
gateway:
platforms:
discord:
extra:
require_admin_for_exec_approval: true # default: false
allow_admin_from:
- "123456789012345678" # only these users may click Approve/Deny
```
**Behavior:**
- **Default off** — exec-approval buttons stay user-scope; any admitted user can approve. Existing installs are unaffected.
- **When on** — the clicker must pass the normal admission check **and** be listed in `allow_admin_from` (the same key the slash-command split uses). The lower-stakes component views (model picker, clarify, update prompt) stay user-scope.
- **Fails closed** — if the toggle is on but `allow_admin_from` is empty, *nobody* can approve and a warning is logged, so the misconfiguration is visible rather than silently locking you out.
## Interactive Model Picker
Send `/model` with no arguments in a Discord channel to open a dropdown-based model picker: