fix(pairing): enforce lockout on approve_code, not just generate_code (#10195) (#21325)

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.
This commit is contained in:
Teknium 2026-05-07 07:18:21 -07:00 committed by GitHub
parent 1baab8771a
commit 145e8ec237
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 66 additions and 1 deletions

View file

@ -73,6 +73,24 @@ def _cmd_approve(store, platform: str, code: str):
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")