Address review feedback on secret store (PR #531)#4
Merged
Conversation
added 8 commits
July 9, 2026 17:41
…(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
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.
Review remediation for PR mpfaffenberger#531 — secret store
This branch (
fix/pr531-review) addresses every review finding on PR #531 as a series of focused, individually reviewable commits. All 11 findings are resolved;pytest tests/test_secret_store.pypasses (71 passed) and ruff is clean.Findings and responses
c4c7bfd5icacls(/inheritance:rthen/grant:r <user>:F, no new dependency), otherwise it warns that the fallback is plaintext. The warning copy is platform-aware, and the Windows permission test no longer skips; it asserts the DACL, or the explicit plaintext warning when ACLs are unavailable.13a18b039e91e9ad202b31f7202b31f7117a47f1e1e2dfcd9e91e9ad9e91e9ad9de46228e6df57d5Commit series