feat(secret-store): add generic OS keyring secret store to OSS core#2
Open
GregKinne wants to merge 19 commits into
Open
feat(secret-store): add generic OS keyring secret store to OSS core#2GregKinne wants to merge 19 commits into
GregKinne wants to merge 19 commits into
Conversation
Introduce code_puppy/secret_store.py, a generic secrets manager that reads/writes via the OS keyring with a permission-hardened JSON file fallback for headless and CI environments. Public API: get_secret / set_secret / delete_secret / keyring_available. - keyring is a hard dependency (plain import, no ImportError guard); the packages we ship always include it. Runtime backend absence (headless/ CI) is detected via keyring_available() and degrades to the fallback. - Fallback writes 0o600 JSON atomically (temp + fchmod + os.replace), repairs loose permissions on read, and warns once per process that fallback storage is active. - Ships no secret values and holds no product-specific key names or migration logic. Migrating specific secrets (puppy_token, API keys) onto this store is deferred to follow-up work, one category per PR. Adds keyring>=24.0.0 to pyproject. 20 unit tests cover the three paths: keyring available, keyring missing, and fallback file behavior.
GregKinne
force-pushed
the
feat/oss-secret-store
branch
from
July 3, 2026 15:22
3dd2c04 to
adf8074
Compare
Store all secrets in a single macOS Keychain item so the user sees at most one Keychain access prompt regardless of secret count. Why - Each Keychain item's ACL is anchored to the calling binary's identity. uv's managed CPython is ad-hoc signed (no stable Team ID), so its identity is its cdhash, which changes on every Python version bump. That invalidates the ACL and prompts the user once per keychain item. With ~12 secrets landing across upcoming subtasks, that is a dozen "Always Allow" dialogs after a routine upgrade. One item means one ACL and therefore one prompt. Design - New code_puppy/secret_store_backends.py: ConsolidatedKeychainBackend, a custom keyring backend that stores a service's secrets as one JSON blob under a single (service, __blob__) keychain item, delegating actual keychain I/O to the stock macOS backend. - Darwin-gated: should_use_consolidated_backend() returns True only on macOS, only when the active backend is the stock macOS one, and only when the user has not pinned a backend (PYTHON_KEYRING_BACKEND). Off macOS the native keyring backend is used unchanged. - Selection is explicit via keyring.set_keyring. priority is set BELOW the stock backend (0.5 < 5) so keyring's automatic priority-based discovery never picks this backend on any platform; the Darwin gate is the single source of truth. The macOS delegate is constructed lazily so merely importing/instantiating the class during cross-platform backend discovery never touches macOS-only code. - secret_store installs the backend lazily and once, on first secret op, via _ensure_backend(). Best-effort: any failure leaves the native backend in place. Concurrency + corruption safety - set/delete hold an in-process lock plus an advisory flock on a lock file beside CONFIG_DIR, covering the full read-modify-write so parallel sessions / MCP subprocesses cannot clobber each other's writes. - A corrupt or non-dict blob raises rather than being treated as empty, so a bad read can never silently overwrite every stored secret. Public API unchanged: get_secret / set_secret / delete_secret / keyring_available are identical on every platform. Consolidation is a backend concern callers never observe. Tests: gating (darwin/stock/user-pinned), blob storage (N secrets share one item, distinct services get distinct items), corruption safety, and best-effort install. Verified end-to-end against the real macOS Keychain (3 secrets -> one item, surgical delete). 35 passed, ruff clean.
Three minimalist bug fixes for the secret store introduced in PR #2: 1. _ensure_backend: catch ImportError when secret_store_backends is unimportable (Windows has no fcntl module). The consolidated macOS backend is irrelevant off macOS anyway. 2. _write_fallback: use hasattr(os, 'fchmod') guard and fall back to os.chmod on the path. os.fchmod is POSIX-only and raises AttributeError on Windows, which the existing except OSError would not catch. 3. set_secret: when the keyring backend is healthy but the write fails (transient error, dismissed Keychain prompt, etc.), warn instead of silently writing to the fallback file. The old behavior stranded the secret because get_secret skips the fallback when keyring_available() is True. Tests added for all three cases.
The fallback path exists for environments without a keyring (headless Linux, CI, Windows). Using os.chmod instead of os.fchmod removes the hasattr guard from the prior commit — one cross-platform call instead of a branching shim. Simpler test, same behavior.
Fix/secret store cross platform
…ervice name Windows Credential Manager encodes blobs as UTF-16-LE and caps them at ~2,560 bytes. Long tokens (e.g. JWTs) routinely exceed this, causing silent write failures (error 1783). Changes: - Split oversized secrets into <=1,200-char chunks, stored as numbered keyring entries with an atomic count marker. Reads reassemble transparently; pre-chunking entries remain readable. - Three-tier write strategy: direct keyring -> chunked keyring -> 0o600 file fallback. get_secret always checks the fallback as a last resort so secrets written there by a prior session are still recoverable. - Add configure_service_name() so downstream distributions can namespace their secrets without bleeding across builds. - Add sys.platform == 'win32' skipif decorators on POSIX permission tests and an fcntl collection guard on the backends test module. - 52 tests pass (9 new chunking + 4 new service-name tests).
…(F9) Caller-supplied names containing ':cp:' could shadow a real secret's chunk metadata or, via delete_secret, destroy an unrelated entry. Add _validate_name (also rejecting empty names) and call it at the top of get/set/delete_secret. Introduces SecretStoreError for later failure-contract work. Addresses review finding: discussion_r3554814765
… (F8)
Two related normalization bugs:
- set_secret('token', ' ') hit an early return, so _keyring_set reported
False -- indistinguishable from a real backend failure -- and set_secret
then warned misleadingly about a 'healthy backend' write failure.
- Universal .strip() on write and read silently mutated secrets that carry
legitimate leading/trailing whitespace.
Now: _validate_value rejects empty/whitespace-only input with ValueError at
the public boundary, and all secret payloads are stored/returned verbatim.
Stripping is confined to config values (service name) and int() parsing of
the chunk-count marker, which tolerates whitespace anyway.
Addresses review finding: discussion_r3554814761
…lers (F3/F4/F10) F3: tempfile.mkstemp() sat outside the try, so its failure raised a raw OSError while every other failure returned False. Moved it inside the try so _write_fallback has one uniform False-on-any-failure contract. F4: set_secret() ignored the _write_fallback() result and returned normally after losing the credential. It now raises SecretStoreError when the keyring is unavailable AND the fallback write fails, so a read-only/full filesystem can no longer masquerade as success. F10: delete_secret() had the same swallowed-return bug -- worse for a delete, since 'delete' would report success while the plaintext secret survived. It now raises SecretStoreError when a required scrub write fails (and stays silent when there was nothing to scrub). Addresses review findings: discussion_r3554790360, discussion_r3554790363, and the delete_secret note in pullrequestreview-4666753894
…hy set (F5/F6) F5: the fallback read-modify-write was unlocked, so two processes (main app + MCP subprocess) could each read the document, add a different key, and the second writer would clobber the first (lost update). Added a cross-platform advisory lock (fcntl on POSIX, msvcrt on Windows -- the platform where the chunked path actually activates) plus an in-process thread lock, wrapping the entire read->mutate->write cycle via new _fallback_set/_fallback_delete helpers. F6: a successful keyring write never scrubbed the fallback, so a rotated-out secret lingered in plaintext forever and could be silently resurrected if the keyring entry later vanished. set_secret now calls _fallback_scrub(name) after a healthy keyring write (best-effort under the same lock; warns but does not fail the set, since the keyring already holds the source of truth). Addresses review findings: discussion_r3554790366, discussion_r3554814753
The keyring tier scoped entries by _service_name, but the fallback was a flat
{name: value} dict indexed only by secret name. In fallback mode, distribution
A could read, overwrite, or delete distribution B's secrets -- the isolation
promised by configure_service_name() did not hold for the file tier.
The on-disk shape is now {service_name: {name: value}}. _read_fallback_doc()
reads the full nested document (and transparently migrates a legacy flat file
under the default 'code-puppy' service so historical secrets stay readable
without leaking into another namespace). _read_fallback() returns just the
current service's slice, and the locked _fallback_set/_delete/_scrub helpers
mutate only that slice, pruning empty service buckets.
Addresses review finding: discussion_r3554790358
…chmod (F1) The fallback warning claimed 'permission-hardened ... (mode 0o600)', but chmod(0o600) does not establish an owner-only NTFS DACL on Windows -- it only toggles the read-only attribute. On the older/stripped-down Windows machines where this fallback actually activates, the file was effectively plaintext with default inheritance while the warning implied it was protected. The tests also skipped permission verification on Windows. Now _harden_permissions branches by platform: chmod 0o600 on POSIX, and an owner-only NTFS DACL via icacls (/inheritance:r + /grant:r <user>:F, no extra dependency) on Windows. The outcome is recorded so _warn_fallback_active tells the truth: it states the NTFS ACL when applied, and warns the file is PLAINTEXT when icacls is unavailable. Module docstring updated to match. The Windows path is now unit-tested (mocked icacls) instead of skipped. Addresses review finding: discussion_r3554790356
…s (F11) warnings.warn is effectively invisible inside the TUI and is deduplicated by the default warnings filter, so the headless-fallback and write-failure notices never reached a human. Added _notify(message) which emits through code_puppy.messaging.emit_warning (imported lazily to avoid an import cycle) and falls back to warnings.warn only when the bus is unavailable, so a notice is never silently dropped. The three user-facing call sites now use _notify. Tests capture bus notices via an autouse fixture instead of pytest.warns. Addresses review finding: the warnings.warn note in pullrequestreview-4666753894
The old chunked overwrite mutated the previous value's chunks in place while
the count key still pointed at them, so a mid-overwrite crash could yield a
'Frankenstein' value; prune-before-commit could leave count=N with chunks
missing; and the only cross-process lock lived in the macOS backend while
chunking activates on Windows, where no such lock exists.
Reworked to the generation-numbered scheme the reviewer proposed:
1. Each chunked write picks a fresh random generation and writes chunks
under name:cp:<gen>:<i>. The previously committed value is untouched.
2. Commit is a single atomic flip of the pointer key to '<gen>:<count>'.
Before the flip the old value is fully readable; after it, the new value
is -- there is no window exposing a torn/mixed value, and no lock is
required for crash-safety.
3. The old generation (and any stale direct/legacy entries) are GC'd best
effort after commit.
Random (not incrementing) generations mean two concurrent writers never share
a chunk namespace; the atomic pointer flip picks the winner. Reads and deletes
understand both the new '<gen>:<count>' pointer and the legacy bare-count
layout, so pre-existing chunked secrets keep working.
Addresses review finding: discussion_r3554814758
Address review feedback on secret store (PR mpfaffenberger#531)
# Conflicts: # pyproject.toml
GregKinne
force-pushed
the
feat/oss-secret-store
branch
from
July 10, 2026 12:52
c26607a to
a58479f
Compare
added 2 commits
July 10, 2026 10:04
Keep the OSS/enterprise secret store byte-identical while removing corporate tells: reword the _BLOB_ACCOUNT comment to 'different distributions' and use neutral service names in the distinct-services backend test. (cherry picked from commit 96da4dd4d526b42ab16fd638de688a82c1853637)
…nt null-backend silent credential loss set_secret() called _keyring_set() unconditionally before consulting keyring_available(). keyring.backends.null.Keyring (priority -1) has a silent no-op set_password, so the write appeared successful while the credential was silently discarded -- and then the fallback was scrubbed, guaranteeing loss. Now the keyring attempt is gated on keyring_available(): priority <= 0 backends (null/fail/headless) route straight to the permission-hardened file fallback instead of trusting an accept-and-discard backend. Tests: new null_keyring fixture (accept-and-discard, distinct from the raises-based missing_keyring), TestNullBackendNoSilentLoss (5 tests) + TestFailBackendFallsBack (1 test). 77 passed, ruff clean.
GregKinne
added a commit
that referenced
this pull request
Jul 22, 2026
* feat(secret-store): add generic OS keyring secret store to OSS core Introduce code_puppy/secret_store.py, a generic secrets manager that reads/writes via the OS keyring with a permission-hardened JSON file fallback for headless and CI environments. Public API: get_secret / set_secret / delete_secret / keyring_available. - keyring is a hard dependency (plain import, no ImportError guard); the packages we ship always include it. Runtime backend absence (headless/ CI) is detected via keyring_available() and degrades to the fallback. - Fallback writes 0o600 JSON atomically (temp + fchmod + os.replace), repairs loose permissions on read, and warns once per process that fallback storage is active. - Ships no secret values and holds no product-specific key names or migration logic. Migrating specific secrets (puppy_token, API keys) onto this store is deferred to follow-up work, one category per PR. Adds keyring>=24.0.0 to pyproject. 20 unit tests cover the three paths: keyring available, keyring missing, and fallback file behavior. * feat(secret-store): add consolidated macOS keyring backend Store all secrets in a single macOS Keychain item so the user sees at most one Keychain access prompt regardless of secret count. Why - Each Keychain item's ACL is anchored to the calling binary's identity. uv's managed CPython is ad-hoc signed (no stable Team ID), so its identity is its cdhash, which changes on every Python version bump. That invalidates the ACL and prompts the user once per keychain item. With ~12 secrets landing across upcoming subtasks, that is a dozen "Always Allow" dialogs after a routine upgrade. One item means one ACL and therefore one prompt. Design - New code_puppy/secret_store_backends.py: ConsolidatedKeychainBackend, a custom keyring backend that stores a service's secrets as one JSON blob under a single (service, __blob__) keychain item, delegating actual keychain I/O to the stock macOS backend. - Darwin-gated: should_use_consolidated_backend() returns True only on macOS, only when the active backend is the stock macOS one, and only when the user has not pinned a backend (PYTHON_KEYRING_BACKEND). Off macOS the native keyring backend is used unchanged. - Selection is explicit via keyring.set_keyring. priority is set BELOW the stock backend (0.5 < 5) so keyring's automatic priority-based discovery never picks this backend on any platform; the Darwin gate is the single source of truth. The macOS delegate is constructed lazily so merely importing/instantiating the class during cross-platform backend discovery never touches macOS-only code. - secret_store installs the backend lazily and once, on first secret op, via _ensure_backend(). Best-effort: any failure leaves the native backend in place. Concurrency + corruption safety - set/delete hold an in-process lock plus an advisory flock on a lock file beside CONFIG_DIR, covering the full read-modify-write so parallel sessions / MCP subprocesses cannot clobber each other's writes. - A corrupt or non-dict blob raises rather than being treated as empty, so a bad read can never silently overwrite every stored secret. Public API unchanged: get_secret / set_secret / delete_secret / keyring_available are identical on every platform. Consolidation is a backend concern callers never observe. Tests: gating (darwin/stock/user-pinned), blob storage (N secrets share one item, distinct services get distinct items), corruption safety, and best-effort install. Verified end-to-end against the real macOS Keychain (3 secrets -> one item, surgical delete). 35 passed, ruff clean. * fix(secret-store): cross-platform guards + stranded-secret warning Three minimalist bug fixes for the secret store introduced in PR #2: 1. _ensure_backend: catch ImportError when secret_store_backends is unimportable (Windows has no fcntl module). The consolidated macOS backend is irrelevant off macOS anyway. 2. _write_fallback: use hasattr(os, 'fchmod') guard and fall back to os.chmod on the path. os.fchmod is POSIX-only and raises AttributeError on Windows, which the existing except OSError would not catch. 3. set_secret: when the keyring backend is healthy but the write fails (transient error, dismissed Keychain prompt, etc.), warn instead of silently writing to the fallback file. The old behavior stranded the secret because get_secret skips the fallback when keyring_available() is True. Tests added for all three cases. * refactor: replace os.fchmod with os.chmod for OS-agnostic fallback The fallback path exists for environments without a keyring (headless Linux, CI, Windows). Using os.chmod instead of os.fchmod removes the hasattr guard from the prior commit — one cross-platform call instead of a branching shim. Simpler test, same behavior. * refactor: transparent chunking + three-tier fallback + configurable service name Windows Credential Manager encodes blobs as UTF-16-LE and caps them at ~2,560 bytes. Long tokens (e.g. JWTs) routinely exceed this, causing silent write failures (error 1783). Changes: - Split oversized secrets into <=1,200-char chunks, stored as numbered keyring entries with an atomic count marker. Reads reassemble transparently; pre-chunking entries remain readable. - Three-tier write strategy: direct keyring -> chunked keyring -> 0o600 file fallback. get_secret always checks the fallback as a last resort so secrets written there by a prior session are still recoverable. - Add configure_service_name() so downstream distributions can namespace their secrets without bleeding across builds. - Add sys.platform == 'win32' skipif decorators on POSIX permission tests and an fcntl collection guard on the backends test module. - 52 tests pass (9 new chunking + 4 new service-name tests). * style: apply ruff format to secret_store and tests * fix(secret-store): enforce reserved ':cp:' namespace on secret names (F9) Caller-supplied names containing ':cp:' could shadow a real secret's chunk metadata or, via delete_secret, destroy an unrelated entry. Add _validate_name (also rejecting empty names) and call it at the top of get/set/delete_secret. Introduces SecretStoreError for later failure-contract work. Addresses review finding: discussion_r3554814765 * fix(secret-store): stop stripping secrets; reject empty values loudly (F8) Two related normalization bugs: - set_secret('token', ' ') hit an early return, so _keyring_set reported False -- indistinguishable from a real backend failure -- and set_secret then warned misleadingly about a 'healthy backend' write failure. - Universal .strip() on write and read silently mutated secrets that carry legitimate leading/trailing whitespace. Now: _validate_value rejects empty/whitespace-only input with ValueError at the public boundary, and all secret payloads are stored/returned verbatim. Stripping is confined to config values (service name) and int() parsing of the chunk-count marker, which tolerates whitespace anyway. Addresses review finding: discussion_r3554814761 * fix(secret-store): uniform fallback failure contract, surfaced to callers (F3/F4/F10) F3: tempfile.mkstemp() sat outside the try, so its failure raised a raw OSError while every other failure returned False. Moved it inside the try so _write_fallback has one uniform False-on-any-failure contract. F4: set_secret() ignored the _write_fallback() result and returned normally after losing the credential. It now raises SecretStoreError when the keyring is unavailable AND the fallback write fails, so a read-only/full filesystem can no longer masquerade as success. F10: delete_secret() had the same swallowed-return bug -- worse for a delete, since 'delete' would report success while the plaintext secret survived. It now raises SecretStoreError when a required scrub write fails (and stays silent when there was nothing to scrub). Addresses review findings: discussion_r3554790360, discussion_r3554790363, and the delete_secret note in pullrequestreview-4666753894 * fix(secret-store): lock fallback RMW + scrub stale plaintext on healthy set (F5/F6) F5: the fallback read-modify-write was unlocked, so two processes (main app + MCP subprocess) could each read the document, add a different key, and the second writer would clobber the first (lost update). Added a cross-platform advisory lock (fcntl on POSIX, msvcrt on Windows -- the platform where the chunked path actually activates) plus an in-process thread lock, wrapping the entire read->mutate->write cycle via new _fallback_set/_fallback_delete helpers. F6: a successful keyring write never scrubbed the fallback, so a rotated-out secret lingered in plaintext forever and could be silently resurrected if the keyring entry later vanished. set_secret now calls _fallback_scrub(name) after a healthy keyring write (best-effort under the same lock; warns but does not fail the set, since the keyring already holds the source of truth). Addresses review findings: discussion_r3554790366, discussion_r3554814753 * fix(secret-store): namespace the fallback file by service name (F2) The keyring tier scoped entries by _service_name, but the fallback was a flat {name: value} dict indexed only by secret name. In fallback mode, distribution A could read, overwrite, or delete distribution B's secrets -- the isolation promised by configure_service_name() did not hold for the file tier. The on-disk shape is now {service_name: {name: value}}. _read_fallback_doc() reads the full nested document (and transparently migrates a legacy flat file under the default 'code-puppy' service so historical secrets stay readable without leaking into another namespace). _read_fallback() returns just the current service's slice, and the locked _fallback_set/_delete/_scrub helpers mutate only that slice, pruning empty service buckets. Addresses review finding: discussion_r3554790358 * fix(secret-store): honest owner-only hardening on Windows, not false chmod (F1) The fallback warning claimed 'permission-hardened ... (mode 0o600)', but chmod(0o600) does not establish an owner-only NTFS DACL on Windows -- it only toggles the read-only attribute. On the older/stripped-down Windows machines where this fallback actually activates, the file was effectively plaintext with default inheritance while the warning implied it was protected. The tests also skipped permission verification on Windows. Now _harden_permissions branches by platform: chmod 0o600 on POSIX, and an owner-only NTFS DACL via icacls (/inheritance:r + /grant:r <user>:F, no extra dependency) on Windows. The outcome is recorded so _warn_fallback_active tells the truth: it states the NTFS ACL when applied, and warns the file is PLAINTEXT when icacls is unavailable. Module docstring updated to match. The Windows path is now unit-tested (mocked icacls) instead of skipped. Addresses review finding: discussion_r3554790356 * fix(secret-store): route user-facing notices through the messaging bus (F11) warnings.warn is effectively invisible inside the TUI and is deduplicated by the default warnings filter, so the headless-fallback and write-failure notices never reached a human. Added _notify(message) which emits through code_puppy.messaging.emit_warning (imported lazily to avoid an import cycle) and falls back to warnings.warn only when the bus is unavailable, so a notice is never silently dropped. The three user-facing call sites now use _notify. Tests capture bus notices via an autouse fixture instead of pytest.warns. Addresses review finding: the warnings.warn note in pullrequestreview-4666753894 * fix(secret-store): generation-numbered crash-safe chunking (F7) The old chunked overwrite mutated the previous value's chunks in place while the count key still pointed at them, so a mid-overwrite crash could yield a 'Frankenstein' value; prune-before-commit could leave count=N with chunks missing; and the only cross-process lock lived in the macOS backend while chunking activates on Windows, where no such lock exists. Reworked to the generation-numbered scheme the reviewer proposed: 1. Each chunked write picks a fresh random generation and writes chunks under name:cp:<gen>:<i>. The previously committed value is untouched. 2. Commit is a single atomic flip of the pointer key to '<gen>:<count>'. Before the flip the old value is fully readable; after it, the new value is -- there is no window exposing a torn/mixed value, and no lock is required for crash-safety. 3. The old generation (and any stale direct/legacy entries) are GC'd best effort after commit. Random (not incrementing) generations mean two concurrent writers never share a chunk namespace; the atomic pointer flip picks the winner. Reads and deletes understand both the new '<gen>:<count>' pointer and the legacy bare-count layout, so pre-existing chunked secrets keep working. Addresses review finding: discussion_r3554814758 * scrub: genericize enterprise references in secret store backend Keep the OSS/enterprise secret store byte-identical while removing corporate tells: reword the _BLOB_ACCOUNT comment to 'different distributions' and use neutral service names in the distinct-services backend test. (cherry picked from commit 96da4dd4d526b42ab16fd638de688a82c1853637) * fix(secret_store): gate keyring write on keyring_available() to prevent null-backend silent credential loss set_secret() called _keyring_set() unconditionally before consulting keyring_available(). keyring.backends.null.Keyring (priority -1) has a silent no-op set_password, so the write appeared successful while the credential was silently discarded -- and then the fallback was scrubbed, guaranteeing loss. Now the keyring attempt is gated on keyring_available(): priority <= 0 backends (null/fail/headless) route straight to the permission-hardened file fallback instead of trusting an accept-and-discard backend. Tests: new null_keyring fixture (accept-and-discard, distinct from the raises-based missing_keyring), TestNullBackendNoSilentLoss (5 tests) + TestFailBackendFallsBack (1 test). 77 passed, ruff clean. --------- Co-authored-by: Gregory Kinne <gregory.kinne@walmart.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Upstream a secrets manager into OSS Code Puppy
Adds a generic OS keyring secret store to OSS core.
Mechanism only — it migrates no existing secrets and ships no secret values.
What's in this PR
code_puppy/secret_store.py(new) — generic secrets manager:get_secret/set_secret/delete_secret/keyring_available.no keyring backend is available (headless / CI).
pyproject.toml— addskeyring>=24.0.0(and the correspondinguv.lockupdate). Does not addmsal-extensions.tests/test_secret_store.py(new) — 20 tests covering the threerequired paths: keyring available, keyring missing, and fallback file.
Design decisions
keyringimports fine but can still raise at runtime on a headless boxwith no backend.
keyring_available()detects this and the get/set/delete paths degrade to the file fallback instead of crashing.
0o600JSON, atomic write(temp +
fchmod+os.replace), permission-repair on read, and aone-shot warning that fallback storage is active (per CP-020 audit
guidance: never silently fall back to plaintext).
Verification
pytest tests/test_secret_store.py→ 20 passed.ruff check→ clean.upstream/main; forkmainfast-forwarded toupstream first so this diff is a single clean commit.