mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-04 17:17:56 +08:00
Compare commits
1 Commits
claude-cod
...
feat/disco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a36623652 |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user