Skip to content

fix(pairing): reset the failed-approval counter on a successful approval - #62145

Closed
solyanviktor-star wants to merge 1 commit into
NousResearch:mainfrom
solyanviktor-star:fix/pairing-reset-failures-on-success
Closed

fix(pairing): reset the failed-approval counter on a successful approval#62145
solyanviktor-star wants to merge 1 commit into
NousResearch:mainfrom
solyanviktor-star:fix/pairing-reset-failures-on-success

Conversation

@solyanviktor-star

Copy link
Copy Markdown
Contributor

Problem

PairingStore.approve_code()'s success path never clears _failures:{platform}. The counter is incremented on every non-matching code (_record_failed_attempt), persisted in _rate_limits.json, and only ever reset to 0 when it reaches MAX_FAILED_ATTEMPTS (which fires the 1-hour lockout). So it counts failures over the gateway's entire lifetime, not consecutive ones — and the sibling guard _is_rate_limited in the same class correctly uses a time window, so this counter is the odd one out.

Concrete failure (normal operation)

Pairing codes are 8 random chars, so mistyping one is common. An owner who pairs users over time and fat-fingers the code on 4 separate occasions — each time immediately retyping it correctly and successfully pairing — accumulates _failures:telegram = 4 (never reset, survives restarts).

A later single fresh typo → _record_failed_attemptfails = 5lockout for 1 hour. The owner now types the correct code, but _is_locked_out gates approve_code (the #10195 fix) and returns None — the valid pairing is rejected and all approval on that platform is dead for an hour, from five non-consecutive typos.

Fix

Reset the counter on a successful approval — standard brute-force-guard semantics, where the counter tracks consecutive failures. Added _reset_failed_attempts() (mirrors _record_failed_attempt) and call it from the approve_code success path.

This does not weaken protection: an attacker cannot produce a success without a valid code, and 5 consecutive wrong attempts still lock out exactly as before. It only stops isolated, interleaved-with-success typos from accumulating.

Tests

Added test_successful_approval_resets_failure_counter to TestLockout: MAX_FAILED_ATTEMPTS - 1 wrong attempts, then a valid approval, asserts _failures is cleared and a subsequent single typo does not trip the lockout. Fails on current code (counter stays at 4), passes with the fix.

python -m pytest tests/gateway/test_pairing.py — 52 passed, 1 skipped.

Dedup

#21325 (merged) and #10248 (closed) added/duplicated the lockout check in approve_code; neither resets the failure counter on success. This is a distinct defect.

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data P2 Medium — degraded but workaround exists labels Jul 10, 2026
@falkoro

falkoro commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Verified the premise on current main: _record_failed_attempt (gateway/pairing.py:611) increments _failures:{platform} in the persisted _rate_limits.json, and the only reset anywhere is the one inside the lockout branch itself — nothing clears the counter on a successful approval. So isolated typos genuinely accumulate across the gateway's entire lifetime until the Nth one trips a spurious lockout, rejecting a code that was otherwise valid. Consecutive-failure semantics is the standard behavior for lockout counters and this is the minimal way to get it.

Nice detail: the reset only writes the file when the counter is non-zero, so the happy path adds no I/O.

Ran tests/gateway/test_pairing.py on the branch: 44 passed (Python 3.12). LGTM.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused regression fix. The premise remains live on current main: unmatched codes call _record_failed_attempt() at gateway/pairing.py:523-525, and that helper persists _failures:{platform} while only resetting it after threshold lockout at gateway/pairing.py:611-623. The valid approval path at gateway/pairing.py:527-537 has no corresponding reset.

The new regression test covers the relevant interleaving case rather than duplicating the existing active-lockout test at tests/gateway/test_pairing.py:543-577. The change is narrowly scoped to pairing state and does not add configuration, tools, or prompt/session state. git log and git diff from the PR base to current main showed no changes in either touched file, so the two-hunk salvage should be mechanical.

Automated hermes-sweeper review.

@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 11, 2026
approve_code()'s success path never cleared _failures:{platform}. The
counter is incremented on every non-matching code, persisted in
_rate_limits.json, and only ever reset to 0 when it reaches
MAX_FAILED_ATTEMPTS (firing the lockout). So it counts failures over the
gateway's entire lifetime, not consecutive ones.

An owner who mistypes a pairing code on a handful of separate occasions
— each time immediately retyping it correctly and successfully pairing —
accumulates those isolated typos. A later single fresh typo then hits
MAX_FAILED_ATTEMPTS and locks the whole platform out for an hour, at
which point _is_locked_out gates approve_code and even the *correct*
code is rejected.

Reset the counter on a successful approval, matching standard
brute-force-guard semantics (the counter tracks consecutive failures).
This does not weaken protection: an attacker cannot produce a success
without a valid code, and 5 consecutive wrong attempts still lock out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@solyanviktor-star
solyanviktor-star force-pushed the fix/pairing-reset-failures-on-success branch from a06742e to 893eb9d Compare July 28, 2026 10:47
@solyanviktor-star

Copy link
Copy Markdown
Contributor Author

Rebased onto current main — the branch was still based on a July 12 checkout, so its CI runs had aged out and no checks were being reported. Fresh runs are in flight now.

Premise re-verified on today's main: approve_code's success path (gateway/pairing.py, after the hash match) still deletes the pending entry and calls _approve_user without touching _failures:<platform>, so the counter only ever resets when the lockout itself fires in _record_failed_attempt. Scattered typos by the legitimate owner — each followed by a successful approval — therefore accumulate across the whole lifetime of _rate_limits.json and eventually lock out a correct code for an hour. The regression test still fails without the fix (verified by reverting gateway/pairing.py to main: test_successful_approval_resets_failure_counter fails), 52 passed with it.

@OutThisLife

Copy link
Copy Markdown
Collaborator

Correct call — the counter tracks consecutive failures, so a success has to clear it. Cherry-picked into #74427 with authorship preserved; the reset moved into the shared _finish_approval there so the new request-id grant path gets it too, not just approve_code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants