Skip to content

fix(auth): narrow keyring exception handler + log root cause#80

Merged
cmeans-claude-dev[bot] merged 1 commit into
mainfrom
fix/keyring-handler-narrow
May 1, 2026
Merged

fix(auth): narrow keyring exception handler + log root cause#80
cmeans-claude-dev[bot] merged 1 commit into
mainfrom
fix/keyring-handler-narrow

Conversation

@cmeans-claude-dev
Copy link
Copy Markdown
Contributor

@cmeans-claude-dev cmeans-claude-dev Bot commented May 1, 2026

Summary

Closes #38. The bare except Exception keyring handler in core/auth.py:147-148 collapsed every failure mode into the same useless "Keyring not available." debug line. Now narrows to typed handlers (KeyringError, OSError) with exc_info=True, and bumps the D-Bus socket-missing pre-check from DEBUG to INFO with three concrete remediations.

Behavior change

Before After
except Exception: logger.debug("Keyring not available.") swallows everything except KeyringError as e: logger.debug("Keyring access failed: %s", e, exc_info=True) + except OSError as e: logger.debug("Keyring OS-level error: %s", e, exc_info=True)
mcp-synology check -v shows generic "Keyring not available." regardless of cause -v shows the actual exception class, message, and traceback
Library bugs (e.g. an AttributeError from a corrupted keyring backend) silently swallowed Library bugs propagate so they can be triaged
Linux headless service + missing D-Bus socket → DEBUG-only "D-Bus socket not found at %s; keyring may not work" INFO-level: names the socket path AND points at three concrete remediations (real desktop session, dbus-run-session, SYNOLOGY_* env vars)

The defense-in-depth property is preserved: a keyring blow-up still falls through to the next strategy in the resolver chain (config-file or env credentials), so a working auth.username/password configuration continues to work even when the keychain is locked.

Coverage of typed errors

keyring.errors.KeyringError is the base class. The hierarchy this catches:

  • NoKeyringError — common on headless hosts with no backend installed
  • KeyringLocked — operator-actionable on macOS (unlock the keychain)
  • InitError — backend installed but couldn't initialize (e.g. D-Bus reach failure surfaced via the keyring layer)
  • PasswordSetError / PasswordDeleteError — write-path errors (we only read here, but these descend from KeyringError and are correctly caught for free)

OSError covers the D-Bus-socket-present-but-unreachable variant separately, which the keyring library re-raises as a raw OSError rather than wrapping in an InitError.

QA

Manual tests

    • Run uv run pytest — 553 passed at 96.13% coverage.
    • Run uv run pytest tests/core/test_auth.py::TestKeyringErrorHandling -v — 3 passed.
    • Run uv run pytest tests/core/test_auth.py::TestDbusSocketMissing -v — 1 passed; the strengthened assertion now requires the INFO-level remediation hint.
    • Run uv run ruff check src/ tests/ scripts/ — clean.
    • Run uv run ruff format --check src/ tests/ scripts/ — clean.
    • Run uv run mypy src/ scripts/ — clean.
    • Spot-check git grep -n 'except Exception' src/mcp_synology/core/auth.py — only the dispatch-loop except Exception as e: # noqa: BLE001 — best-effort dispatch remains (that one is intentional, lives inside the on-reauth callback fan-out).
    • On a Linux box without an active D-Bus session, SYNOLOGY_LOG_LEVEL=info mcp-synology check should surface the new INFO line "D-Bus socket not found at /run/user//bus; OS keyring is unavailable. Run 'mcp-synology setup' from a real desktop session..." instead of falling through silently to "no credentials".

Verification I already ran

Check Result
uv run pytest 553 passed, 100 deselected, 96.13% coverage
uv run ruff check src/ tests/ scripts/ clean
uv run ruff format --check src/ tests/ scripts/ 72 files already formatted
uv run mypy src/ scripts/ clean (30 files, strict-mode)
Discovery: python -c "import keyring.errors; print([n for n in dir(keyring.errors) if not n.startswith('_')])" confirms KeyringError is the base class for all typed keyring failures (KeyringLocked, NoKeyringError, InitError, PasswordSetError, PasswordDeleteError)

🤖 Generated with Claude Code

cmeans-claude-dev Bot added a commit that referenced this pull request May 1, 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
@codecov-commenter
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

cmeans
cmeans previously approved these changes May 1, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@cmeans cmeans added the QA Active QA is actively reviewing; Dev should not push changes label May 1, 2026
Closes #38. core/auth.py:147-148 was a bare `except Exception:` with
a flat `logger.debug("Keyring not available.")` that hid every
keyring failure mode behind one line: locked macOS keychain,
NoKeyringError on a headless host, InitError, OS-level reach
errors, and library bugs. Operators running `mcp-synology check -v`
saw the same useless "Keyring not available." regardless of why.

Narrows to two typed handlers:
- `except KeyringError as e` for the typed-error case (covers
  KeyringLocked, NoKeyringError, InitError, PasswordSetError,
  PasswordDeleteError, and any other keyring.errors.* class)
- `except OSError as e` for D-Bus reach errors and OS-level
  permission failures on the keychain backend

Both log at DEBUG with exc_info=True so verbose-mode surfaces the
actual exception type, message, and traceback. Genuine bugs are no
longer caught — they propagate.

Bumped the pre-keyring D-Bus socket pre-check log from DEBUG to
INFO and rewrote it with three concrete remediations:
- run `mcp-synology setup` from a real desktop session
- wrap with `dbus-run-session`
- set credentials via SYNOLOGY_* env vars to bypass keyring

The Linux + headless-service path is the most common keyring
failure mode and previously needed -v to surface the actual cause.

Updated tests/core/test_auth.py::_no_keyring() fixture to raise
`keyring.errors.NoKeyringError` instead of bare Exception so
existing tests exercise the production-shaped error path.

New TestKeyringErrorHandling (3 cases):
- KeyringError logged with exc_info and message text at DEBUG
- OSError logged separately at DEBUG with exc_info
- Defense-in-depth: keyring blow-up doesn't block credential
  resolution from config/env

Strengthened TestDbusSocketMissing::
test_dbus_not_set_when_socket_missing_on_linux to assert the new
INFO-level remediation hint contains the socket path AND all three
remediation strings.

553 unit tests pass at 96.13% coverage; ruff/mypy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cmeans-claude-dev cmeans-claude-dev Bot force-pushed the fix/keyring-handler-narrow branch from 75f61e6 to ec630b3 Compare May 1, 2026 15:40
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA and removed QA Active QA is actively reviewing; Dev should not push changes Ready for QA Dev work complete — QA can begin review labels May 1, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

New commits pushed while QA was active. QA review invalidated — resetting to Awaiting CI.

@cmeans-claude-dev
Copy link
Copy Markdown
Contributor Author

Rebased on main after PR #79 merge

PR #79 (mcp-publisher v1.5.0 → v1.7.6) just merged into main, so this branch picked up a CHANGELOG conflict on the shared ## Unreleased > ### Fixed section. Rebased and force-pushed fix/keyring-handler-narrow onto the new main:

Force-pushed with --force-with-lease to be safe. Verification on the rebased head matches the pre-rebase verification: 553 unit tests pass at 96.13% coverage, ruff/format/mypy clean.

@github-actions github-actions Bot added Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@cmeans cmeans added the QA Active QA is actively reviewing; Dev should not push changes label May 1, 2026
@github-actions github-actions Bot removed the Ready for QA Dev work complete — QA can begin review label May 1, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA Round 1 — PASS

Closes the keyring-handler narrowing I called out as a real-impact security item back in the PR #74 (SafeSkill) review. Clean fix shape, comprehensive tests, defense-in-depth preserved.

Issue #38 acceptance criteria

AC Coverage
Narrow except to (KeyringError, OSError) ✓ Two separate typed handlers at auth.py:158-175
Log actual exception at DEBUG with exc_info=True ✓ Both branches; tests assert record.exc_info is not None, not just message presence
INFO-level hint when D-Bus socket missing auth.py:131-140 rewritten from DEBUG to INFO with three concrete remediations (real desktop session, dbus-run-session, SYNOLOGY_* env vars)
Unit test: KeyringError logged with message, not silently swallowed TestKeyringErrorHandling::test_keyring_error_logged_with_exc_info_at_debug plus a regression assertion that the legacy flat "Keyring not available." message does NOT appear
CHANGELOG ### Fixed entry ✓ Present, #80-linked, accurate test count and coverage figure

Verification

Check Result
uv run pytest 553 passed (+3 new TestKeyringErrorHandling), 100 deselected, 96.13% coverage
Targeted TestKeyringErrorHandling + TestDbusSocketMissing 4/4 pass directly
uv run ruff check src/ tests/ scripts/ clean
uv run ruff format --check src/ tests/ scripts/ 72 files already formatted
uv run mypy src/ scripts/ clean (30 files, strict-mode)
grep -n 'except Exception' src/mcp_synology/core/auth.py only auth.py:282 (the on-reauth callback dispatch loop with # noqa: BLE001 — best-effort dispatch, QA-approved on #73). The pre-fix auth.py:147-148 bare-except is gone.
Required CI on ec630b3 13/13 green (vdsm completed SUCCESS)

Test-quality notes

A few things I want to call out as good (not findings):

  • The _no_keyring() fixture update — bumping the side-effect from bare Exception to keyring.errors.NoKeyringError — keeps the production-shaped error path exercised in every existing test that uses the fixture. Without this, the new typed handlers would only be exercised in the three new cases and the rest of the suite would test against an unrealistic error type.
  • test_keyring_failure_does_not_block_config_credentials is the right defense-in-depth check: the bare-except previously had a "feature" (silent swallow → fall through) that the narrow handlers must preserve. Tests prove they do.
  • The strengthened test_dbus_not_set_when_socket_missing_on_linux asserts the INFO message contains the socket path AND mcp-synology setup AND SYNOLOGY_USERNAME — three independent strings — so a partial regression (e.g. the remediation hint truncated) would still fail the test. That's stronger than what AC#3 strictly requires.
  • Manual test #8 is a Linux-box verification of the operator-visible INFO line; the strengthened unit test asserts the exact output that would surface, so #8 is implicitly covered without needing to actually deploy the build to a headless Linux service.

PR-body manual tests 1–8 all checked.

Verdict

Ready for QA Signoff. Final maintainer call.

Side note: this is the third of the security-relevant items I listed in the PR #74 SafeSkill review (after #69/#71 atomic writes and #66 whitespace credential filtering already merged). #46 supply-chain hardening, #40 version-string validation, #39 background update-check error visibility, #44 PyPI gate are still open if the security-posture work is on a roll.

@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

Applying Ready for QA Signoff as the final act of round 1. All 5 #38 acceptance criteria satisfied; 4/4 targeted tests pass; defense-in-depth fallback chain confirmed; only legitimate except Exception remaining in auth.py is the on-reauth dispatch loop (QA-approved on #73). 553/553, 96.13%, ruff/mypy clean, 13/13 required CI green on ec630b3 (the post-rebase head). Final maintainer call.

@cmeans cmeans added Ready for QA Signoff QA passed — ready for maintainer final review and merge QA Approved Manual QA testing completed and passed and removed QA Active QA is actively reviewing; Dev should not push changes Ready for QA Signoff QA passed — ready for maintainer final review and merge labels May 1, 2026
@cmeans-claude-dev cmeans-claude-dev Bot merged commit 4e93b9b into main May 1, 2026
34 checks passed
@cmeans-claude-dev cmeans-claude-dev Bot deleted the fix/keyring-handler-narrow branch May 1, 2026 15:49
cmeans-claude-dev Bot added a commit that referenced this pull request May 1, 2026
Addresses QA F1 on PR #81. An earlier Edit on this branch used a
prefix of the #80 entry as `old_string`, which silently truncated
the rest of #80's description AND concatenated it to the end of the
new #81 entry. Net effect was the merged #80 entry on this branch
read just "closes #38." while #81 had ~3KB of unrelated tail.

Restored #80 to its origin/main form (full multi-sentence
description) and trimmed the spurious tail from #81. `git diff
origin/main -- CHANGELOG.md` now shows exactly one new line — the
intended #81 entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cmeans-claude-dev cmeans-claude-dev Bot mentioned this pull request May 1, 2026
5 tasks
cmeans-claude-dev Bot added a commit that referenced this pull request May 2, 2026
## Summary

Cuts v0.5.2, shipping six PRs that landed since v0.5.1 (2026-05-01):

- **#79** mcp-publisher v1.5.0 → v1.7.6 (registry OIDC audience fix)
- **#80** keyring exception handler narrowing (closes #38)
- **#81** bg update-check executor timeout + log (closes #39)
- **#82** pygments 2.19.2 → 2.20.0 (GHSA-5239-wwwm-4pmq, ReDoS)
- **#83** `--revert` version-string validation (closes #40)
- **#85** per-path serial for `move_files` + `copy_files` (closes #84)

## Why now

Two recent bug fixes (#83, #85) are user-visible enough to warrant
shipping, and #84 in particular is a confusing silent-no-op regression
on multi-file moves — getting that to PyPI promptly matters. The four
post-0.5.1 quality fixes (#79#82) are stacked behind it.

This release also exercises **#79's mcp-publisher v1.7.6 pin
end-to-end** so the registry can catch up to current. v0.5.1's registry
entry is missing because #79 landed AFTER the v0.5.1 tag-push, and
`actions/checkout@v6` resolved to the tag's commit on re-runs of the
failed `publish-registry` job — the fix wasn't picked up. The v0.5.2 tag
will pull the correct pin from main.

## State after merge

Bug-labeled issue queue is empty. The structural multi-path-serial fix
family (delete + getinfo + move + copy + restore) is now complete on
real DSM 7.x — every File Station write tool that takes a `paths:
list[str]` issues one DSM task per path, sidestepping the
comma-joined-multipath quirk that #68 and #84 each surfaced.

## Files changed

- `pyproject.toml` — version 0.5.1 → 0.5.2
- `server.json` — auto-synced via `python scripts/sync-server-json.py`
- `uv.lock` — refreshed via `uv lock`
- `CHANGELOG.md` — `## Unreleased` (with the six entries above) renamed
to `## 0.5.2 (2026-05-01)`, fresh empty `## Unreleased` inserted above
it for the next cycle

## Test plan

- [x] CI green on this branch (lint, typecheck, test 3.11/3.12/3.13,
vdsm integration tests, version-sync, validate-server-json)
- [ ] After merge: tag `v0.5.2` push fires `publish.yml`; PyPI publish
succeeds
- [ ] After merge: `publish-registry` job succeeds end-to-end (this is
the validation point for #79's fix — the failure mode in v0.5.1 was
`invalid audience: expected https://registry.modelcontextprotocol.io,
got [mcp-registry]`)
- [ ] After tag: `mcp-synology --check-update` from a v0.5.1 install
reports v0.5.2 available; `uv tool install mcp-synology@latest` upgrades
cleanly
- [ ] Smoke (post-install): two-file `move_files` actually moves both
files (the #84 regression scenario)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

QA Approved Manual QA testing completed and passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keyring exception handler swallows all failures — narrow and log root cause

2 participants