Skip to content

Address review feedback on secret store (PR #531)#4

Merged
GregKinne merged 8 commits into
feat/oss-secret-storefrom
fix/pr531-review
Jul 10, 2026
Merged

Address review feedback on secret store (PR #531)#4
GregKinne merged 8 commits into
feat/oss-secret-storefrom
fix/pr531-review

Conversation

@GregKinne

@GregKinne GregKinne commented Jul 9, 2026

Copy link
Copy Markdown
Owner

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.py passes (71 passed) and ruff is clean.

Findings and responses

# Severity Finding Commit Our response
F1 Blocker Windows fallback is plaintext; chmod(0o600) doesn't give owner-only NTFS DACL c4c7bfd5 The original wording overpromised on Windows. The hardening now branches by platform: POSIX uses 0o600, and Windows applies an explicit owner-only NTFS DACL via icacls (/inheritance:r then /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.
F2 Blocker Fallback lookup bypasses the configured service namespace 13a18b03 Fixed. The fallback document is now nested by service name, so configure_service_name() isolates the file tier the same way it isolates keyring entries. Added a cross-service isolation test showing distribution B cannot read, overwrite, or delete distribution A's fallback secret.
F4 Blocker set_secret() ignores _write_fallback() failure and returns normally after losing the credential 9e91e9ad Fixed. set_secret() now checks the _write_fallback() result and raises a SecretStoreError when persistence fails, so a full or read-only filesystem can no longer report success. Added a test that forces the write to fail and asserts the error propagates.
F5 High Fallback read-modify-write is unlocked — concurrent writers lose updates 202b31f7 Fixed. Fallback mutations now run under a cross-platform advisory lock (fcntl/msvcrt) covering the full read-modify-write, so concurrent processes no longer overwrite each other's updates. Added a concurrent-writers test that spawns competing writers and asserts no updates are lost.
F6 High Successful keyring write never scrubs fallback — stale plaintext resurrection 202b31f7 Fixed. A successful keyring set now scrubs any stale fallback entry (under the F5 lock), so rotated secrets are not left in plaintext or read back from the fallback. Added a test asserting the fallback entry is gone after a healthy keyring set.
F7 High Chunked overwrite isn't crash-safe and has no cross-process lock on Windows 117a47f1 Reworked. Chunked writes now use generation-numbered entries (name:cp::) and commit by flipping a single pointer key, then GC the old generation. The old value stays readable until the atomic flip, so a crash mid-overwrite cannot produce a partial value. Added crash-mid-overwrite and shrink tests.
F9 High ':cp:' reserved namespace isn't enforced — secret names can shadow/destroy chunk metadata e1e2dfcd Fixed. Secret names are now validated through a single helper that rejects the reserved ':cp:' substring with a ValueError, closing the shadow and destroy collision paths. Added tests for the count-marker shadow, the delete-a-different-secret case, and the chunk-0 overwrite.
F10 Medium delete_secret() also ignores _write_fallback() return (worse: 'delete' lies on read-only FS) 9e91e9ad Fixed. delete_secret() now honors the _write_fallback() return and raises when the scrub fails, so a read-only or full filesystem can no longer report a successful delete while the plaintext secret survives. Added a delete-failure-propagation test.
F3 Medium Inconsistent fallback failure contract (mkstemp OSError escapes; other failures return False) 9e91e9ad Tightened. mkstemp() is now inside the try/except so _write_fallback has a single, consistent False-on-failure contract. Call sites now honor that return (see F4/F10).
F8 Medium Empty value = false-alarm warning + silent no-op; .strip() silently mutates secrets 9de46228 Fixed. Empty and whitespace-only values now raise ValueError at the API boundary instead of being reported as a backend failure. We also stopped .strip()-ing secret payloads, so credentials with leading or trailing whitespace round-trip byte-for-byte; stripping is confined to internal metadata (the chunk-count marker).
F11 Low warnings.warn is invisible in the TUI and deduped by the default filter e6df57d5 Fixed. User-facing notices (headless-fallback active, keyring-write-failed) now go through the messaging bus so they render in the TUI instead of being suppressed by the warnings filter. The once-per-process dedupe is preserved.

Commit series

117a47f1 fix(secret-store): generation-numbered crash-safe chunking (F7)
e6df57d5 fix(secret-store): route user-facing notices through the messaging bus (F11)
c4c7bfd5 fix(secret-store): honest owner-only hardening on Windows, not false chmod (F1)
13a18b03 fix(secret-store): namespace the fallback file by service name (F2)
202b31f7 fix(secret-store): lock fallback RMW + scrub stale plaintext on healthy set (F5/F6)
9e91e9ad fix(secret-store): uniform fallback failure contract, surfaced to callers (F3/F4/F10)
9de46228 fix(secret-store): stop stripping secrets; reject empty values loudly (F8)
e1e2dfcd fix(secret-store): enforce reserved ':cp:' namespace on secret names (F9)

Gregory Kinne 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
@GregKinne
GregKinne merged commit fb54df4 into feat/oss-secret-store Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant