mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
PairingStore.approve_code() didn't consult _is_locked_out(), so after MAX_FAILED_ATTEMPTS bad approvals the lockout flag was set but a valid code still got accepted — any pending code (legitimately issued or attacker-obtained) could be approved during the 1-hour lockout window, nullifying the brute-force protection. - gateway/pairing.py: lockout check runs in approve_code() right after _cleanup_expired, before the pending lookup. Returns None on lockout. - tests/gateway/test_pairing.py: test_lockout_blocks_code_approval pins the regression — reporter's exact reproducer (generate valid code, exhaust attempts with WRONGCODE, try to approve valid code) must return None and leave is_approved == False. Also pins recovery: once lockout expires, the still-pending code approves normally. - hermes_cli/pairing.py: _cmd_approve distinguishes the two None cases. On lockout, prints 'Platform locked out... clears in N minutes. To reset sooner, delete the _lockout:<platform> entry from _rate_limits.json' instead of the misleading 'Code not found or expired' message. 29/29 pairing tests pass; E2E-verified with reporter's exact Python reproducer.
115 lines
4.2 KiB
Python
115 lines
4.2 KiB
Python
"""
|
|
CLI commands for the DM pairing system.
|
|
|
|
Usage:
|
|
hermes pairing list # Show all pending + approved users
|
|
hermes pairing approve <platform> <code> # Approve a pairing code
|
|
hermes pairing revoke <platform> <user_id> # Revoke user access
|
|
hermes pairing clear-pending # Clear all expired/pending codes
|
|
"""
|
|
|
|
def pairing_command(args):
|
|
"""Handle hermes pairing subcommands."""
|
|
from gateway.pairing import PairingStore
|
|
|
|
store = PairingStore()
|
|
action = getattr(args, "pairing_action", None)
|
|
|
|
if action == "list":
|
|
_cmd_list(store)
|
|
elif action == "approve":
|
|
_cmd_approve(store, args.platform, args.code)
|
|
elif action == "revoke":
|
|
_cmd_revoke(store, args.platform, args.user_id)
|
|
elif action == "clear-pending":
|
|
_cmd_clear_pending(store)
|
|
else:
|
|
print("Usage: hermes pairing {list|approve|revoke|clear-pending}")
|
|
print("Run 'hermes pairing --help' for details.")
|
|
|
|
|
|
def _cmd_list(store):
|
|
"""List all pending and approved users."""
|
|
pending = store.list_pending()
|
|
approved = store.list_approved()
|
|
|
|
if not pending and not approved:
|
|
print("No pairing data found. No one has tried to pair yet~")
|
|
return
|
|
|
|
if pending:
|
|
print(f"\n Pending Pairing Requests ({len(pending)}):")
|
|
print(f" {'Platform':<12} {'Code':<10} {'User ID':<20} {'Name':<20} {'Age'}")
|
|
print(f" {'--------':<12} {'----':<10} {'-------':<20} {'----':<20} {'---'}")
|
|
for p in pending:
|
|
print(
|
|
f" {p['platform']:<12} {p['code']:<10} {p['user_id']:<20} "
|
|
f"{(p.get('user_name') or ''):<20} {p['age_minutes']}m ago"
|
|
)
|
|
else:
|
|
print("\n No pending pairing requests.")
|
|
|
|
if approved:
|
|
print(f"\n Approved Users ({len(approved)}):")
|
|
print(f" {'Platform':<12} {'User ID':<20} {'Name':<20}")
|
|
print(f" {'--------':<12} {'-------':<20} {'----':<20}")
|
|
for a in approved:
|
|
print(f" {a['platform']:<12} {a['user_id']:<20} {(a.get('user_name') or ''):<20}")
|
|
else:
|
|
print("\n No approved users.")
|
|
|
|
print()
|
|
|
|
|
|
def _cmd_approve(store, platform: str, code: str):
|
|
"""Approve a pairing code."""
|
|
platform = platform.lower().strip()
|
|
code = code.upper().strip()
|
|
|
|
result = store.approve_code(platform, code)
|
|
if result:
|
|
uid = result["user_id"]
|
|
name = result.get("user_name") or ""
|
|
display = f"{name} ({uid})" if name else uid
|
|
print(f"\n Approved! User {display} on {platform} can now use the bot~")
|
|
print(" They'll be recognized automatically on their next message.\n")
|
|
elif store._is_locked_out(platform):
|
|
# Disambiguate: approve_code returns None for both invalid codes
|
|
# and lockout. Tell the operator it's lockout so they don't chase
|
|
# a "wrong code" rabbit hole (#10195).
|
|
import time as _time
|
|
limits = store._load_json(store._rate_limit_path())
|
|
lockout_until = limits.get(f"_lockout:{platform}", 0)
|
|
remaining = max(0, int(lockout_until - _time.time()))
|
|
mins = remaining // 60
|
|
print(
|
|
f"\n Platform '{platform}' is locked out after too many failed "
|
|
f"approval attempts."
|
|
)
|
|
print(f" Lockout clears in ~{mins} minute(s).")
|
|
print(
|
|
" To reset sooner, delete the '_lockout:{0}' entry from "
|
|
"~/.hermes/platforms/pairing/_rate_limits.json\n".format(platform)
|
|
)
|
|
else:
|
|
print(f"\n Code '{code}' not found or expired for platform '{platform}'.")
|
|
print(" Run 'hermes pairing list' to see pending codes.\n")
|
|
|
|
|
|
def _cmd_revoke(store, platform: str, user_id: str):
|
|
"""Revoke a user's access."""
|
|
platform = platform.lower().strip()
|
|
|
|
if store.revoke(platform, user_id):
|
|
print(f"\n Revoked access for user {user_id} on {platform}.\n")
|
|
else:
|
|
print(f"\n User {user_id} not found in approved list for {platform}.\n")
|
|
|
|
|
|
def _cmd_clear_pending(store):
|
|
"""Clear all pending pairing codes."""
|
|
count = store.clear_pending()
|
|
if count:
|
|
print(f"\n Cleared {count} pending pairing request(s).\n")
|
|
else:
|
|
print("\n No pending requests to clear.\n")
|