diff --git a/code_puppy/secret_store.py b/code_puppy/secret_store.py new file mode 100644 index 000000000..6689b906b --- /dev/null +++ b/code_puppy/secret_store.py @@ -0,0 +1,814 @@ +"""Generic OS keyring secret store for Code Puppy. + +Reads and writes secrets through the operating system keyring, with a +permission-hardened JSON file fallback as a final safety net. + +Write strategy (three tiers, in order): + 1. **Direct keyring write** -- used when the value fits in one entry. + 2. **Chunked keyring write** -- the value is split into ≤``_CHUNK_SIZE`` + pieces when the OS imposes a per-entry size cap. The primary real-world + case is Windows Credential Manager, which rejects blobs larger than + ~2 560 bytes UTF-16-LE (error 1783); long tokens routinely exceed + this. Chunking keeps the keyring as the source of truth. + 3. **Permission-hardened file fallback** -- used only when both keyring + strategies fail (genuinely broken backend, backend crash, headless CI). + Written atomically and restricted to the owner: ``0o600`` on POSIX, and + an owner-only NTFS DACL (via ``icacls``) on Windows. If the Windows ACL + cannot be applied, the fallback warning says so plainly -- the file is + then plaintext with default inheritance, not owner-only protected. + +Read strategy: + The keyring is queried first (with transparent chunk reassembly). The + fallback file is always consulted as a last resort so secrets written there + by a previous session (after exhausting both keyring options) are still + recoverable even when the keyring subsequently becomes healthy. + +The keyring service name is configurable via ``configure_service_name`` so +each distribution can namespace its secrets and never read, copy, or alias +secrets across builds. The default is ``"code-puppy"``; downstream +distributions override it at startup. + +Public API +---------- +``keyring_available()`` + Report whether a usable keyring backend is configured. +``configure_service_name(name)`` + Override the keyring service name used for all secret operations. +``get_secret(name)`` / ``set_secret(name, value)`` / ``delete_secret(name)`` + Three-tier secret operations (keyring direct → keyring chunked → file). +""" + +from __future__ import annotations + +import contextlib +import json +import os +import subprocess +import sys +import tempfile +import threading +import warnings + +import keyring + +from code_puppy.config import CONFIG_DIR + +# Namespace under which every secret is stored in the OS keyring. Downstream +# distributions call ``configure_service_name`` to use a distinct name so +# secrets never bleed across builds. +_service_name = "code-puppy" + +# The default service namespace. A legacy flat fallback file (pre per-service +# scoping) is migrated under this name so it stays readable on the default +# build without leaking into another distribution's namespace. +_DEFAULT_SERVICE = "code-puppy" + +# Permission-hardened JSON fallback used only when the keyring backend is +# unavailable (headless boxes, minimal CI containers, etc.). +_FALLBACK_FILE = os.path.join(CONFIG_DIR, "secrets.json") +_FALLBACK_MODE = 0o600 + +# Emit the "fallback storage is active" warning at most once per process so +# we do not spam the console on every read/write. +_warned_fallback = False + +# The consolidated macOS backend is installed lazily, once, on first use. +_backend_installed = False + + +def _ensure_backend() -> None: + """Install the consolidated macOS backend once, before any secret op. + + Off macOS (or when the user pinned a backend) this is a no-op and the + native ``keyring`` backend is used unchanged. Best-effort: a failure + leaves the native backend in place. + """ + global _backend_installed + if _backend_installed: + return + _backend_installed = True + try: + from code_puppy.secret_store_backends import ( + install_consolidated_backend_if_appropriate, + ) + except ImportError: + # secret_store_backends imports fcntl (POSIX-only); on Windows + # the consolidated macOS backend is irrelevant anyway. + return + + install_consolidated_backend_if_appropriate() + + +def get_service_name() -> str: + """Return the current keyring service name.""" + return _service_name + + +def configure_service_name(name: str) -> None: + """Override the keyring service name used for all secret operations. + + Call this early at startup -- before any get/set/delete calls -- so + secrets are namespaced per distribution. Downstream distributions + call this from their ``startup`` callback. The default is + ``"code-puppy"``. + """ + global _service_name + name = str(name).strip() + if not name: + raise ValueError("service name must be non-empty") + _service_name = name + + +# Maximum characters per keyring entry. Windows Credential Manager encodes +# credential blobs as UTF-16-LE (2 bytes per char) and caps them at ~2 560 +# bytes, giving a ~1 280-char ceiling. We stay conservatively below that so +# typical ASCII padding in the JWT header/signature doesn't push us over. +_CHUNK_SIZE = 1200 + +# Suffix tokens used to build chunk-related keyring entry names. The ``cp`` +# prefix scopes them to Code Puppy and makes accidental collisions with real +# secret names essentially impossible. +_CHUNK_NS = ":cp:" +_COUNT_SUFFIX = ":cp:n" + + +class SecretStoreError(RuntimeError): + """Raised when a secret cannot be persisted or removed as requested. + + Distinct from a *missing* secret (``get_secret`` returns ``None``): this + signals an active failure -- e.g. a fallback write to a read-only or full + filesystem -- so callers never mistake a lost credential for success. + """ + + +def _validate_name(name: str) -> str: + """Validate a caller-supplied secret name. + + The chunk machinery reserves the ``:cp:`` token to build internal entry + names (``:cp:`` for chunks, ``:cp:n`` for the commit + marker). A caller-supplied name containing ``:cp:`` could therefore + shadow a real secret's chunk metadata or, via ``delete_secret``, destroy + an unrelated entry. Because this module is a generic store whose names + may be built from user- or config-derived strings, we reject the reserved + token outright rather than trust that "nobody would name a secret that." + """ + if not isinstance(name, str) or not name: + raise ValueError("secret name must be a non-empty string") + if _CHUNK_NS in name: + raise ValueError( + f"secret name {name!r} contains the reserved substring " + f"{_CHUNK_NS!r}; it is used internally for chunk metadata" + ) + return name + + +def _validate_value(value: str) -> str: + """Validate a caller-supplied secret value. + + Empty or whitespace-only values are rejected with a ``ValueError`` so an + empty write can never be confused with a backend failure (the old code + silently no-oped and then emitted a misleading "keyring write failed" + warning). Values with *content* plus surrounding whitespace are allowed + and stored verbatim -- secrets with significant leading/trailing + whitespace exist and must not be silently mutated. + """ + if not isinstance(value, str): + raise ValueError("secret value must be a string") + if not value.strip(): + raise ValueError("secret value must be non-empty") + return value + + +def _chunk_count_key(name: str) -> str: + """Keyring entry name for the commit pointer. + + The pointer value is ``":"`` for generation-numbered writes, + or a bare ``""`` for legacy (pre-generation) writes. + """ + return f"{name}{_COUNT_SUFFIX}" + + +def _chunk_key(name: str, gen: str, i: int) -> str: + """Keyring entry name for chunk *i* of generation *gen* of *name*.""" + return f"{name}{_CHUNK_NS}{gen}:{i}" + + +def _legacy_chunk_key(name: str, i: int) -> str: + """Keyring entry name for chunk *i* under the pre-generation layout.""" + return f"{name}{_CHUNK_NS}{i}" + + +def _new_generation() -> str: + """A short random generation token (8 hex chars). + + Random rather than incrementing so two concurrent writers never pick the + same generation and corrupt each other's chunk set: each writes under its + own namespace and the single atomic pointer flip decides the winner. + """ + return os.urandom(4).hex() + + +def _parse_pointer(raw: str | None) -> tuple[str | None, int] | None: + """Parse a commit pointer into ``(generation, count)``. + + ``generation`` is ``None`` for legacy bare-count pointers. Returns + ``None`` when the pointer is absent or corrupt (treated as no value). + """ + if raw is None: + return None + if ":" in raw: + gen, _, count = raw.partition(":") + try: + return (gen, int(count)) + except ValueError: + return None + try: + return (None, int(raw)) + except ValueError: + return None + + +def _gen_chunk_key(name: str, parsed: tuple[str | None, int], i: int) -> str: + """Chunk key for index *i* given a parsed pointer (handles legacy).""" + gen = parsed[0] + return _legacy_chunk_key(name, i) if gen is None else _chunk_key(name, gen, i) + + +# --------------------------------------------------------------------------- +# Keyring availability + low-level access +# --------------------------------------------------------------------------- + + +def keyring_available() -> bool: + """Return True when a usable keyring backend is configured. + + A backend with ``priority <= 0`` (for example the fail/null backend on + a headless Linux box) is treated as unavailable, so callers degrade to + the file fallback instead of writing into a black hole. + """ + _ensure_backend() + try: + backend = keyring.get_keyring() + except Exception: + return False + priority = getattr(backend, "priority", None) + if priority is None: + return True + try: + return float(priority) > 0 + except Exception: + return True + + +def _kr_get_raw(name: str) -> str | None: + """Read one keyring entry verbatim; ``None`` on error or absence. + + The stored value is returned byte-for-byte (no ``.strip()``): a secret + with legitimate leading/trailing whitespace must round-trip unchanged. + A truly empty string is treated as absence. + """ + try: + value = keyring.get_password(_service_name, name) + except Exception: + return None + if not value: + return None + return str(value) + + +def _kr_set_raw(name: str, value: str) -> bool: + """Write one keyring entry; return ``False`` on any error.""" + try: + keyring.set_password(_service_name, name, value) + except Exception: + return False + return True + + +def _kr_del_raw(name: str) -> bool: + """Delete one keyring entry; return ``False`` on any error.""" + try: + keyring.delete_password(_service_name, name) + except Exception: + return False + return True + + +# --------------------------------------------------------------------------- +# Chunk-aware keyring helpers (transparent to callers) +# --------------------------------------------------------------------------- + + +def _keyring_get(name: str) -> str | None: + """Read a logical secret, transparently reassembling chunks if present. + + Reads the commit pointer first. When present, reassembles the chunks of + the generation it names (new layout) or the legacy flat chunk layout. + Falls back to a direct single-entry read for values that never needed + chunking. + """ + parsed = _parse_pointer(_kr_get_raw(_chunk_count_key(name))) + if parsed is not None: + n = parsed[1] + parts: list[str] = [] + for i in range(n): + chunk = _kr_get_raw(_gen_chunk_key(name, parsed, i)) + if chunk is None: + return None # partial/torn read -- treat as absent + parts.append(chunk) + assembled = "".join(parts) + return assembled or None + + # No chunk metadata -- try a plain single-entry read. + return _kr_get_raw(name) + + +def _delete_chunks(name: str) -> None: + """Best-effort removal of the committed chunk set (pointer + its chunks). + + Only removes the generation named by the current pointer plus the pointer + itself; orphaned generations from crashed writers are swept by + ``_gc_generations``. + """ + parsed = _parse_pointer(_kr_get_raw(_chunk_count_key(name))) + if parsed is None: + return + for i in range(parsed[1]): + _kr_del_raw(_gen_chunk_key(name, parsed, i)) + _kr_del_raw(_chunk_count_key(name)) + + +def _gc_generation(name: str, gen: str, count: int) -> None: + """Best-effort removal of one generation's chunk entries.""" + i = 0 + # Delete the known count, then keep going while stray higher chunks exist + # (defensive against a prior larger write of the same generation). + while i < count or _kr_get_raw(_chunk_key(name, gen, i)) is not None: + _kr_del_raw(_chunk_key(name, gen, i)) + i += 1 + + +def _keyring_set(name: str, value: str) -> bool: + """Write a logical secret, chunking automatically when it exceeds _CHUNK_SIZE. + + **Small values** (len <= _CHUNK_SIZE): written as a single entry under + *name*; any prior chunk set is removed. + + **Large values** (len > _CHUNK_SIZE): written with a *generation-numbered* + scheme that is crash-safe without relying on a lock (the chunked path only + activates on Windows, where the cross-process flock does not exist): + + 1. Pick a fresh random generation and write every chunk under + ``name:cp::``. The previously committed value is untouched. + 2. Atomically flip the single pointer key to ``":"``. Until + this instant a crash leaves the old value fully readable; after it, + the new value is fully readable. There is no window where the + pointer names a half-written or mixed ("Frankenstein") value. + 3. Best-effort GC of the previously committed generation and the stale + direct entry. + + Returns ``True`` on success, ``False`` if any keyring write fails. The + value is stored verbatim; empty input is rejected at the public boundary. + """ + if len(value) <= _CHUNK_SIZE: + if not _kr_set_raw(name, value): + return False + _delete_chunks(name) # clean up any old chunked write + return True + + # --- Chunked write path (generation-numbered) --- + prev = _parse_pointer(_kr_get_raw(_chunk_count_key(name))) + gen = _new_generation() + chunks = [value[i : i + _CHUNK_SIZE] for i in range(0, len(value), _CHUNK_SIZE)] + + # 1. Write the new generation's chunks. Old value stays intact. + for idx, chunk in enumerate(chunks): + if not _kr_set_raw(_chunk_key(name, gen, idx), chunk): + # Roll back only our own (uncommitted) generation. + for j in range(idx): + _kr_del_raw(_chunk_key(name, gen, j)) + return False + + # 2. Commit: atomically flip the pointer to the new generation. + if not _kr_set_raw(_chunk_count_key(name), f"{gen}:{len(chunks)}"): + for idx in range(len(chunks)): + _kr_del_raw(_chunk_key(name, gen, idx)) + return False + + # 3. Best-effort GC of the old generation and any stale direct entry. + if prev is not None and prev[0] is not None and prev[0] != gen: + _gc_generation(name, prev[0], prev[1]) + elif prev is not None and prev[0] is None: + for i in range(prev[1]): + _kr_del_raw(_legacy_chunk_key(name, i)) + _kr_del_raw(name) + return True + + +def _keyring_delete(name: str) -> bool: + """Delete a logical secret, removing chunks if present.""" + direct = _kr_del_raw(name) + _delete_chunks(name) + return direct + + +# --------------------------------------------------------------------------- +# Permission-hardened JSON file fallback (secure I/O helper) +# --------------------------------------------------------------------------- + + +# Cross-process advisory lock guarding the fallback read-modify-write. Atomic +# os.replace() prevents truncated reads but not *lost updates*: two processes +# (e.g. the main app and an MCP subprocess) can each read the document, add a +# different key, and the second writer clobbers the first. The lock serializes +# the whole read->mutate->write cycle. The chunked keyring path activates on +# Windows, so this lock is cross-platform (fcntl on POSIX, msvcrt on Windows). +_FALLBACK_LOCK_FILE = os.path.join(CONFIG_DIR, ".secrets.lock") +_fallback_thread_lock = threading.RLock() + +try: + import fcntl + + def _lock_fd(fd: int) -> None: + fcntl.flock(fd, fcntl.LOCK_EX) + + def _unlock_fd(fd: int) -> None: + fcntl.flock(fd, fcntl.LOCK_UN) +except ImportError: # pragma: no cover - platform-specific (Windows) + import msvcrt + + def _lock_fd(fd: int) -> None: + # Byte-range lock on the first byte; blocks until acquired. + msvcrt.locking(fd, msvcrt.LK_LOCK, 1) + + def _unlock_fd(fd: int) -> None: + try: + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + except OSError: + pass + + +@contextlib.contextmanager +def _fallback_lock(): + """Serialize the fallback read-modify-write across threads and processes. + + Best-effort: if the lock file cannot be created (e.g. read-only config + dir), we degrade to the in-process thread lock alone rather than wedge + secret operations. The subsequent _write_fallback will surface any real + persistence failure. + """ + with _fallback_thread_lock: + try: + os.makedirs(CONFIG_DIR, exist_ok=True) + fd = os.open(_FALLBACK_LOCK_FILE, os.O_CREAT | os.O_RDWR, _FALLBACK_MODE) + except OSError: + yield + return + try: + _lock_fd(fd) + yield + finally: + try: + _unlock_fd(fd) + finally: + os.close(fd) + + +def _notify(message: str) -> None: + """Surface a user-facing notice through the messaging bus. + + ``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. Routing through the bus makes + them render. Falls back to ``warnings.warn`` when the bus is unavailable + (early startup, non-TUI callers, or an import failure) so a notice is never + silently dropped. + """ + try: + from code_puppy.messaging import emit_warning + + emit_warning(message) + except Exception: + warnings.warn(message, stacklevel=2) + + +def _warn_fallback_active() -> None: + global _warned_fallback + if _warned_fallback: + return + _warned_fallback = True + if sys.platform == "win32" and _windows_acl_hardened is False: + detail = ( + "secrets are stored in PLAINTEXT at " + f"{_FALLBACK_FILE}; an owner-only NTFS ACL could not be applied " + "(icacls unavailable on this system). Treat this file as sensitive." + ) + elif sys.platform == "win32": + detail = ( + "secrets are stored in the fallback file at " + f"{_FALLBACK_FILE}, restricted to your account by an owner-only " + "NTFS ACL." + ) + else: + detail = ( + "secrets are stored in the permission-hardened fallback file at " + f"{_FALLBACK_FILE} (mode 0o600)." + ) + warnings_msg = ( + "No OS keyring backend is available; " + + detail + + " This is intended for headless/CI use only." + ) + _notify(warnings_msg) + + +# Tracks whether the most recent Windows ACL hardening attempt succeeded, so +# the fallback warning can be honest about the actual on-disk protection. +# None until the first Windows hardening attempt. +_windows_acl_hardened: bool | None = None + + +def _harden_windows(path: str) -> bool: + """Apply an owner-only NTFS DACL to *path* via ``icacls`` (no extra deps). + + Removes inherited ACEs and grants Full control to the current user only. + Best-effort: minimal or older Windows images -- exactly where this + fallback activates -- may lack a usable ``icacls``. + """ + user = os.environ.get("USERNAME") + if not user: + return False + try: + subprocess.run( + ["icacls", path, "/inheritance:r"], + check=True, + capture_output=True, + timeout=10, + ) + subprocess.run( + ["icacls", path, "/grant:r", f"{user}:F"], + check=True, + capture_output=True, + timeout=10, + ) + return True + except (OSError, subprocess.SubprocessError): + return False + + +def _harden_permissions(path: str) -> bool: + """Restrict *path* to the owner, honestly per platform. + + POSIX: ``chmod 0o600``. Windows: an owner-only NTFS DACL via ``icacls``, + because ``chmod(0o600)`` does **not** establish owner-only protection on + NTFS -- it only toggles the read-only attribute. The Windows outcome is + recorded so ``_warn_fallback_active`` can tell the user the truth. + """ + global _windows_acl_hardened + if sys.platform == "win32": + _windows_acl_hardened = _harden_windows(path) + return _windows_acl_hardened + try: + os.chmod(path, _FALLBACK_MODE) + return True + except OSError: + return False + + +def _read_fallback_doc() -> dict[str, dict[str, str]]: + """Read the raw fallback file as a service-namespaced document. + + The on-disk shape is ``{service_name: {secret_name: value}}`` so each + distribution's fallback secrets are isolated the same way keyring entries + are (F2). Permissions are repaired on read. A legacy *flat* + ``{secret_name: value}`` file (written before per-service scoping existed) + is migrated under the default service namespace so historical secrets stay + readable under the default build without leaking into another + distribution's namespace. + """ + try: + # Repair permissions on read: if the file leaked to a broader mode + # (bad umask, restored backup), tighten it back to owner-only. + if sys.platform == "win32": + _harden_permissions(_FALLBACK_FILE) + else: + try: + current = os.stat(_FALLBACK_FILE).st_mode & 0o777 + if current != _FALLBACK_MODE: + os.chmod(_FALLBACK_FILE, _FALLBACK_MODE) + except OSError: + pass + with open(_FALLBACK_FILE, encoding="utf-8") as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return {} + if not isinstance(data, dict): + return {} + # Current (nested) format: service -> {name: value}. + nested = {k: v for k, v in data.items() if isinstance(v, dict)} + if nested: + return nested + # Legacy flat format: migrate under the default service namespace. + flat = {k: v for k, v in data.items() if isinstance(v, str)} + return {_DEFAULT_SERVICE: flat} if flat else {} + + +def _read_fallback() -> dict[str, str]: + """Return the current service's fallback slice (used by ``get_secret``).""" + return dict(_read_fallback_doc().get(_service_name, {})) + + +def _write_fallback(data: dict[str, dict[str, str]]) -> bool: + """Atomically write the fallback file with ``0o600`` permissions. + + Writes to a temp file in the same directory, ``chmod`` s it before it + ever holds content the target will keep, then ``os.replace`` s it into + place so a crash mid-write can never leave a truncated secrets file. + + Returns ``True`` on success and ``False`` on *any* failure (including a + ``mkstemp`` that fails on a read-only or full filesystem). The contract + is uniform so callers can act on it -- see ``set_secret``/``delete_secret``. + """ + try: + os.makedirs(CONFIG_DIR, exist_ok=True) + except OSError: + return False + + tmp = None + try: + fd, tmp = tempfile.mkstemp(dir=CONFIG_DIR, suffix=".tmp") + _harden_permissions(tmp) + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, _FALLBACK_FILE) + except OSError: + if tmp is not None: + try: + os.unlink(tmp) + except OSError: + pass + return False + return True + + +def _fallback_set(name: str, value: str) -> bool: + """Add/update a fallback entry under the cross-process lock (F5), scoped to + the current service namespace (F2).""" + with _fallback_lock(): + doc = _read_fallback_doc() + doc.setdefault(_service_name, {})[name] = value + return _write_fallback(doc) + + +def _fallback_delete(name: str) -> bool | None: + """Remove a fallback entry (current service only) under the lock. + + Returns ``True`` on a successful scrub, ``False`` if the rewrite failed, + and ``None`` when there was nothing to remove (so callers don't treat a + no-op as a failure). + """ + with _fallback_lock(): + doc = _read_fallback_doc() + slice_ = doc.get(_service_name, {}) + if name not in slice_: + return None + del slice_[name] + if not slice_: + doc.pop(_service_name, None) + return _write_fallback(doc) + + +def _fallback_scrub(name: str) -> None: + """Best-effort removal of a stale fallback entry after a healthy keyring + write (F6), scoped to the current service namespace (F2). + + A successful keyring write must not leave a rotated-out secret sitting in + plaintext on disk, where it could later be resurrected if the keyring + entry vanishes. The keyring already holds the source of truth here, so a + failure to rewrite the file is not fatal -- but it is worth surfacing. + """ + with _fallback_lock(): + doc = _read_fallback_doc() + slice_ = doc.get(_service_name, {}) + if name not in slice_: + return + del slice_[name] + if not slice_: + doc.pop(_service_name, None) + if not _write_fallback(doc): + _notify( + f"Secret {name!r} was written to the keyring but its stale " + f"plaintext copy in {_FALLBACK_FILE} could not be removed " + "(read-only or full filesystem?)." + ) + + +# --------------------------------------------------------------------------- +# Public high-level API +# --------------------------------------------------------------------------- + + +def get_secret(name: str) -> str | None: + """Return a secret by name, or ``None`` when it is not stored. + + Resolution order: + 1. OS keyring -- direct read or transparent chunk reassembly. + 2. Permission-hardened fallback file -- always consulted as a last + resort so secrets written there by a previous session (after + exhausting both keyring options) are still recoverable. + """ + _validate_name(name) + _ensure_backend() + value = _keyring_get(name) + if value: + return value + + # Always check the fallback file: a prior set_secret may have ended up + # there after both keyring paths failed. Only emit the headless warning + # when the keyring backend itself is unavailable. + if not keyring_available(): + _warn_fallback_active() + stored = _read_fallback().get(name) + if not stored: + return None + return str(stored) + + +def set_secret(name: str, value: str) -> None: + """Persist a secret by name. + + When a usable keyring backend is present (``keyring_available()``) + attempts, in order: + 1. Direct keyring write (small values). + 2. Chunked keyring write (oversized values, e.g. Windows CM cap). + If the backend is unavailable (priority <= 0, e.g. the null/fail + backends or a headless box) the keyring is skipped entirely and the + secret goes straight to the permission-hardened JSON file fallback -- + never trusting a no-op backend's silent "success". + 3. Permission-hardened JSON file fallback (backend unavailable, or + both keyring strategies failed despite a healthy backend). + """ + _validate_name(name) + _validate_value(value) + _ensure_backend() + + # F12: Only trust the keyring when a usable backend is present. A backend + # with priority <= 0 (e.g. keyring.backends.null.Keyring) is treated as + # unavailable -- crucially, its set_password is a *silent no-op* that + # returns without raising, so an unconditional _keyring_set() would report + # success while the credential is discarded (and, worse, then scrub the + # fallback). Gating on keyring_available() routes straight to the hardened + # fallback instead of losing the secret. The fail backend (priority 0, + # raises) already fell through correctly; this closes the null-backend + # silent-loss hole and honors the availability contract for both. + if keyring_available(): + if _keyring_set(name, value): + # F6: scrub any stale plaintext copy so a rotated secret can't + # linger on disk and be resurrected later if the keyring entry + # vanishes. + _fallback_scrub(name) + return + + if keyring_available(): + # Both direct and chunked writes failed despite a healthy backend. + # Unexpected (transient error, backend crash, prompt dismissed). + # Warn so it's diagnosable, then persist to the file so the secret + # is not lost. + _notify( + f"Keyring write failed for {name!r} despite a healthy backend " + "(transient error or backend crash). Storing in the secure file " + f"fallback at {_FALLBACK_FILE}." + ) + else: + _warn_fallback_active() + + if not _fallback_set(name, value): + raise SecretStoreError( + f"Failed to persist secret {name!r}: the OS keyring is unavailable " + f"and the fallback file at {_FALLBACK_FILE} could not be written " + "(read-only or full filesystem?). The secret was NOT saved." + ) + + +def delete_secret(name: str) -> None: + """Best-effort removal of a secret from keyring (and chunks) and fallback. + + Raises ``SecretStoreError`` if the fallback file holds the secret but + cannot be rewritten to scrub it -- otherwise "delete" would report success + while the plaintext secret survives on disk. + """ + _validate_name(name) + _ensure_backend() + _keyring_delete(name) + + # Always scrub the fallback file: a prior write may have ended up there + # even on a system where the keyring is now healthy. + if _fallback_delete(name) is False: + raise SecretStoreError( + f"Failed to remove secret {name!r} from the fallback file at " + f"{_FALLBACK_FILE} (read-only or full filesystem?). The " + "plaintext secret may still be present on disk." + ) diff --git a/code_puppy/secret_store_backends.py b/code_puppy/secret_store_backends.py new file mode 100644 index 000000000..4c515809f --- /dev/null +++ b/code_puppy/secret_store_backends.py @@ -0,0 +1,197 @@ +"""macOS consolidated keyring backend for Code Puppy. + +Stores every secret in a single macOS Keychain item (one JSON blob) so +the user sees at most one Keychain access prompt regardless of how many +secrets Code Puppy stores. + +Why this exists +--------------- +A macOS Keychain item carries an ACL naming which binaries may read it +without prompting. The ACL is anchored to the calling binary's identity. +The interpreter that talks to Keychain is uv's managed CPython, which is +ad-hoc signed (no stable Team ID), so its identity is its cdhash. Every +Python version bump produces a new cdhash, misses the stored ACL, and +prompts the user once per keychain item. With a dozen secrets that is a +dozen "Always Allow" dialogs after a routine upgrade. One item means one +ACL and therefore one prompt. + +Scope +----- +This backend is registered only on macOS, only when the active backend is +the stock macOS one, and only when the user has not pinned their own +backend. Off macOS the native ``keyring`` backend is used unchanged. The +``secret_store`` public API does not change; consolidation is a backend +concern that callers never observe. +""" + +from __future__ import annotations + +import contextlib +import fcntl +import json +import os +import sys +import threading + +import keyring +from keyring.backend import KeyringBackend + +from code_puppy.config import CONFIG_DIR + +# All secrets for a given service live under this single, fixed account. +# The service name is supplied per call by secret_store, so different +# distributions (with distinct service names) get distinct blob items. +_BLOB_ACCOUNT = "__code_puppy_secret_blob__" + +# Cross-process lock file guarding the read-modify-write of the blob. +_LOCK_FILE = os.path.join(CONFIG_DIR, ".secrets.lock") + +# In-process guard. flock covers other processes; this covers threads in +# this process cheaply and predictably. +_thread_lock = threading.RLock() + + +@contextlib.contextmanager +def _blob_lock(): + """Serialize the blob read-modify-write across threads and processes. + + Keychain item writes are atomic at the item level, so readers always + see a whole blob (old or new), never a partial one. Writers, however, + must not interleave their read-modify-write cycles or one would clobber + the other's addition. This takes an in-process lock plus an advisory + ``flock`` on a lock file beside ``CONFIG_DIR``. + """ + with _thread_lock: + try: + os.makedirs(CONFIG_DIR, exist_ok=True) + except OSError: + # If we cannot create the lock dir, fall through with only the + # thread lock. Better to proceed than to wedge secret writes. + yield + return + fd = os.open(_LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + +class ConsolidatedKeychainBackend(KeyringBackend): + """Store all of a service's secrets in one keychain item (JSON blob). + + This backend is selected explicitly via ``keyring.set_keyring`` from + ``install_consolidated_backend_if_appropriate``; it is never chosen by + ``keyring``'s automatic priority-based discovery. Its ``priority`` is + deliberately below the stock macOS backend so auto-discovery prefers + the native backend on every platform, and the Darwin gating in + ``should_use_consolidated_backend`` stays the single source of truth + for when consolidation is active. + """ + + # Below the stock macOS backend (5) so keyring's auto-discovery never + # selects this backend on any platform. Selection is explicit via + # set_keyring. Still positive so secret_store.keyring_available() + # treats it as usable once installed. + priority = 0.5 + + def __init__(self) -> None: + super().__init__() + # Delegate is created lazily (see _get_delegate) rather than here, + # so merely constructing this class during keyring's cross-platform + # backend discovery never touches the macOS-only backend. + self._delegate_cache = None + + def _get_delegate(self): + """Lazily construct the stock macOS backend we delegate I/O to. + + This backend only reshapes the key space (one item instead of one + per secret); it does not reimplement keychain I/O. + """ + if self._delegate_cache is None: + from keyring.backends import macOS as macos_backend + + self._delegate_cache = macos_backend.Keyring() + return self._delegate_cache + + # -- blob helpers ------------------------------------------------------- + + def _read_blob(self, service: str) -> dict[str, str]: + """Read and parse the blob. Raise on corruption; never assume empty. + + Treating a corrupt blob as empty would let the next write clobber + every stored secret. Raising instead surfaces the problem and, + because secret_store wraps keyring calls, degrades safely rather + than destroying data. + """ + raw = self._get_delegate().get_password(service, _BLOB_ACCOUNT) + if not raw: + return {} + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError("secret blob is not a JSON object") + return data + + def _write_blob(self, service: str, blob: dict[str, str]) -> None: + self._get_delegate().set_password(service, _BLOB_ACCOUNT, json.dumps(blob)) + + # -- KeyringBackend interface ------------------------------------------ + + def get_password(self, service: str, username: str) -> str | None: + # Item-level atomicity makes a lock unnecessary for a single read. + blob = self._read_blob(service) + value = blob.get(username) + return value if value else None + + def set_password(self, service: str, username: str, password: str) -> None: + with _blob_lock(): + blob = self._read_blob(service) # raises on corruption, no clobber + blob[username] = password + self._write_blob(service, blob) + + def delete_password(self, service: str, username: str) -> None: + with _blob_lock(): + blob = self._read_blob(service) + if username in blob: + del blob[username] + self._write_blob(service, blob) + + +def should_use_consolidated_backend() -> bool: + """Report whether the consolidated backend should be installed. + + True only on macOS, only when the active backend is the stock macOS + one, and only when the user has not pinned a backend via the + ``PYTHON_KEYRING_BACKEND`` env var. A backend configured through + ``keyringrc.cfg`` becomes the active backend, so the stock-backend + check below also defers to it. + """ + if sys.platform != "darwin": + return False + if os.environ.get("PYTHON_KEYRING_BACKEND"): + return False + try: + from keyring.backends import macOS as macos_backend + + current = keyring.get_keyring() + except Exception: + return False + return isinstance(current, macos_backend.Keyring) + + +def install_consolidated_backend_if_appropriate() -> bool: + """Install the consolidated backend when appropriate. Best-effort. + + Returns True when the backend was installed. Never raises: a failure + here must leave the native backend in place rather than break startup. + """ + try: + if not should_use_consolidated_backend(): + return False + keyring.set_keyring(ConsolidatedKeychainBackend()) + return True + except Exception: + return False diff --git a/pyproject.toml b/pyproject.toml index 55e091da9..abb34ba94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "azure-identity>=1.15.0", "anthropic==0.79.0", "boto3>=1.43.9", + "keyring>=24.0.0", "agent-client-protocol>=0.11.0,<0.12", ] dev-dependencies = [ diff --git a/tests/test_secret_store.py b/tests/test_secret_store.py new file mode 100644 index 000000000..86dc37c4f --- /dev/null +++ b/tests/test_secret_store.py @@ -0,0 +1,892 @@ +"""Tests for code_puppy.secret_store -- generic OS keyring wrapper. + +Covers the three paths called out in the subtask: + 1. keyring available -- reads/writes route through the OS keyring + 2. keyring missing -- operations degrade to the file fallback + 3. fallback file -- 0o600 perms, atomic write, read-repair + +Plus the configurable service name for downstream distributions. +""" + +import json +import os +import stat +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from code_puppy import secret_store + + +def _slice(fb, service=None): + """Return the current service's slice of the nested fallback file (F2).""" + doc = json.loads(fb.read_text()) + return doc.get(service or secret_store._service_name, {}) + + +def _chunk_entries(store, name, service=None): + """All chunk-data entry names for *name* in the fake keyring store (F7).""" + svc = service or secret_store._service_name + prefix = f"{name}{secret_store._CHUNK_NS}" + cnt = secret_store._chunk_count_key(name) + return { + k[1] for k in store if k[0] == svc and k[1].startswith(prefix) and k[1] != cnt + } + + +def _pointer(store, name, service=None): + """The parsed commit pointer (gen, count) for *name*, or None.""" + svc = service or secret_store._service_name + return secret_store._parse_pointer( + store.get((svc, secret_store._chunk_count_key(name))) + ) + + +@pytest.fixture(autouse=True) +def _reset_state(): + """Reset process-global state between tests.""" + secret_store._warned_fallback = False + secret_store._service_name = "code-puppy" + secret_store._backend_installed = True # skip lazy install in these tests + secret_store._windows_acl_hardened = None + yield + secret_store._warned_fallback = False + secret_store._service_name = "code-puppy" + secret_store._backend_installed = False + secret_store._windows_acl_hardened = None + + +@pytest.fixture(autouse=True) +def notices(monkeypatch): + """Capture user-facing notices routed through the messaging bus (F11). + + Autouse so no test hits the real bus; request it by name to assert on the + captured messages. + """ + captured: list[str] = [] + import code_puppy.messaging as _msg + + monkeypatch.setattr( + _msg, "emit_warning", lambda m, *a, **k: captured.append(str(m)) + ) + return captured + + +@pytest.fixture +def tmp_fallback(tmp_path, monkeypatch): + cfg_dir = tmp_path / "cfg" + cfg_dir.mkdir() + fallback = cfg_dir / "secrets.json" + monkeypatch.setattr(secret_store, "CONFIG_DIR", str(cfg_dir)) + monkeypatch.setattr(secret_store, "_FALLBACK_FILE", str(fallback)) + return fallback + + +@pytest.fixture +def working_keyring(): + store: dict[tuple[str, str], str] = {} + fake = MagicMock() + fake.get_password = MagicMock( + side_effect=lambda service, name: store.get((service, name)) + ) + + def _set(service, name, value): + store[(service, name)] = value + + def _delete(service, name): + key = (service, name) + if key not in store: + raise Exception("not found") + del store[key] + + fake.set_password = MagicMock(side_effect=_set) + fake.delete_password = MagicMock(side_effect=_delete) + backend = MagicMock() + backend.priority = 10 + fake.get_keyring = MagicMock(return_value=backend) + with patch.object(secret_store, "keyring", fake): + yield fake, store + + +@pytest.fixture +def missing_keyring(): + fake = MagicMock() + fake.get_password = MagicMock(side_effect=Exception("no backend")) + fake.set_password = MagicMock(side_effect=Exception("no backend")) + fake.delete_password = MagicMock(side_effect=Exception("no backend")) + backend = MagicMock() + backend.priority = 0 + fake.get_keyring = MagicMock(return_value=backend) + with patch.object(secret_store, "keyring", fake): + yield fake + + +@pytest.fixture +def null_keyring(): + """Model the null backend (F12): priority <= 0 AND writes silently no-op. + + Unlike ``missing_keyring`` (fail backend, set_password *raises*), the null + backend's set_password/delete_password return None without raising and + get_password always returns None -- a black hole that must NOT be mistaken + for a successful write. Mirrors keyring.backends.null.Keyring (priority -1). + """ + fake = MagicMock() + fake.get_password = MagicMock(return_value=None) + fake.set_password = MagicMock(return_value=None) # silent no-op, no raise + fake.delete_password = MagicMock(return_value=None) + backend = MagicMock() + backend.priority = -1 + fake.get_keyring = MagicMock(return_value=backend) + with patch.object(secret_store, "keyring", fake): + yield fake + + +# --------------------------------------------------------------------------- +# configure_service_name +# --------------------------------------------------------------------------- + + +class TestServiceName: + def test_default(self): + assert secret_store.get_service_name() == "code-puppy" + + def test_override(self): + secret_store.configure_service_name("my-custom-distribution") + assert secret_store.get_service_name() == "my-custom-distribution" + + def test_rejects_empty(self): + with pytest.raises(ValueError, match="non-empty"): + secret_store.configure_service_name(" ") + + def test_secrets_land_under_configured_name(self, working_keyring): + _, store = working_keyring + secret_store.configure_service_name("my-custom-distribution") + secret_store.set_secret("tok", "v") + assert ("my-custom-distribution", "tok") in store + assert ("code-puppy", "tok") not in store + + +# --------------------------------------------------------------------------- +# keyring_available +# --------------------------------------------------------------------------- + + +class TestKeyringAvailable: + def test_true_for_healthy_backend(self, working_keyring): + assert secret_store.keyring_available() is True + + def test_false_for_priority_zero(self, missing_keyring): + assert secret_store.keyring_available() is False + + def test_true_when_priority_missing(self): + fake = MagicMock() + backend = MagicMock(spec=[]) + fake.get_keyring = MagicMock(return_value=backend) + with patch.object(secret_store, "keyring", fake): + assert secret_store.keyring_available() is True + + def test_false_when_get_keyring_raises(self): + fake = MagicMock() + fake.get_keyring = MagicMock(side_effect=RuntimeError("boom")) + with patch.object(secret_store, "keyring", fake): + assert secret_store.keyring_available() is False + + +# --------------------------------------------------------------------------- +# keyring-available path +# --------------------------------------------------------------------------- + + +class TestKeyringPath: + def test_set_then_get_roundtrip(self, working_keyring, tmp_fallback): + _, store = working_keyring + secret_store.set_secret("my_key", "hunter2") + assert store[(secret_store._service_name, "my_key")] == "hunter2" + assert secret_store.get_secret("my_key") == "hunter2" + + def test_get_missing_returns_none(self, working_keyring): + assert secret_store.get_secret("nope") is None + + def test_get_preserves_whitespace(self, working_keyring): + """Secrets are stored/returned verbatim -- leading/trailing whitespace + that is part of the value must survive the round-trip (F8).""" + _, store = working_keyring + store[(secret_store._service_name, "k")] = " spaced " + assert secret_store.get_secret("k") == " spaced " + + def test_delete_removes_from_keyring(self, working_keyring): + _, store = working_keyring + store[(secret_store._service_name, "k")] = "v" + secret_store.delete_secret("k") + assert (secret_store._service_name, "k") not in store + + def test_set_does_not_write_fallback_file(self, working_keyring, tmp_fallback): + """A successful keyring write must never touch the fallback file.""" + secret_store.set_secret("k", "v") + assert not os.path.exists(tmp_fallback) + + def test_get_falls_through_to_fallback_as_last_resort( + self, working_keyring, tmp_fallback + ): + """When keyring has no entry, the fallback file is consulted. + + This covers recovery from a prior session where both keyring strategies + failed and set_secret wrote to the file as a last resort. + """ + tmp_fallback.write_text(json.dumps({"k": "rescued"})) + assert secret_store.get_secret("k") == "rescued" + + def test_keyring_value_takes_precedence_over_fallback( + self, working_keyring, tmp_fallback + ): + """Keyring entry wins when both stores have a value for the same key.""" + _, store = working_keyring + store[(secret_store._service_name, "k")] = "from-keyring" + tmp_fallback.write_text(json.dumps({"k": "from-file"})) + assert secret_store.get_secret("k") == "from-keyring" + + def test_set_warns_and_writes_file_when_keyring_fails( + self, working_keyring, tmp_fallback, notices + ): + """When all keyring writes fail despite a healthy backend, set_secret + emits a bus notice then persists to the fallback file so the secret is + not lost.""" + fake, _ = working_keyring + fake.set_password.side_effect = Exception("backend crash") + + secret_store.set_secret("k", "v") + assert any("despite a healthy backend" in m for m in notices) + + assert tmp_fallback.exists() + assert _slice(tmp_fallback)["k"] == "v" + + def test_delete_also_cleans_fallback(self, working_keyring, tmp_fallback): + """delete_secret always scrubs the fallback file, in case a prior write + landed there as a last resort.""" + tmp_fallback.write_text(json.dumps({"k": "leftover", "other": "keep"})) + secret_store.delete_secret("k") + data = _slice(tmp_fallback) + assert "k" not in data + assert data["other"] == "keep" + + +# --------------------------------------------------------------------------- +# Transparent chunking (Windows Credential Manager size-limit) +# --------------------------------------------------------------------------- + + +class TestChunking: + """Verify that oversized secrets are split into <=_CHUNK_SIZE pieces and + reassembled transparently. Chunking keeps the keyring as the primary store; + the file fallback is only reached if chunking itself also fails. + """ + + def test_large_value_stored_as_chunks(self, working_keyring): + """A value that exceeds _CHUNK_SIZE is split into chunk keys.""" + _, store = working_keyring + svc = secret_store._service_name + big = "A" * (secret_store._CHUNK_SIZE * 2 + 100) # 3 chunks + secret_store.set_secret("tok", big) + + assert _pointer(store, "tok") is not None + assert _pointer(store, "tok")[1] == 3 + assert len(_chunk_entries(store, "tok")) == 3 + # Direct entry must NOT exist (unambiguous read path) + assert (svc, "tok") not in store + + def test_large_value_roundtrip(self, working_keyring): + """get_secret reassembles chunks to return the original value.""" + big = "Z" * (secret_store._CHUNK_SIZE * 3 + 50) + secret_store.set_secret("tok", big) + assert secret_store.get_secret("tok") == big + + def test_small_value_uses_direct_entry(self, working_keyring): + """Values under the chunk threshold go to the direct key, not chunks.""" + _, store = working_keyring + svc = secret_store._service_name + secret_store.set_secret("small", "tiny") + assert store.get((svc, "small")) == "tiny" + assert (svc, secret_store._chunk_count_key("small")) not in store + + def test_stale_chunks_pruned_on_smaller_write(self, working_keyring): + """If a secret shrinks from 3 chunks to 2, the old generation is GC'd.""" + _, store = working_keyring + big3 = "B" * (secret_store._CHUNK_SIZE * 2 + 100) # 3 chunks + secret_store.set_secret("tok", big3) + assert _pointer(store, "tok")[1] == 3 + + big2 = "C" * (secret_store._CHUNK_SIZE + 100) # 2 chunks + secret_store.set_secret("tok", big2) + assert _pointer(store, "tok")[1] == 2 + # Only the new generation's 2 chunks remain -- no orphans. + assert len(_chunk_entries(store, "tok")) == 2 + assert secret_store.get_secret("tok") == big2 + + def test_delete_removes_all_chunk_keys(self, working_keyring): + """delete_secret wipes the pointer and every chunk entry.""" + _, store = working_keyring + svc = secret_store._service_name + big = "D" * (secret_store._CHUNK_SIZE * 2 + 1) # 3 chunks + secret_store.set_secret("tok", big) + secret_store.delete_secret("tok") + + assert (svc, secret_store._chunk_count_key("tok")) not in store + assert len(_chunk_entries(store, "tok")) == 0 + + def test_old_single_entry_still_readable(self, working_keyring): + """Pre-chunking entries written without a count key are still readable.""" + _, store = working_keyring + svc = secret_store._service_name + store[(svc, "legacy")] = "old-value" + assert secret_store.get_secret("legacy") == "old-value" + + def test_missing_chunk_returns_none(self, working_keyring): + """A committed pointer whose chunk is missing (torn read) is treated as + absent rather than returning corrupt data. Uses the legacy layout.""" + _, store = working_keyring + svc = secret_store._service_name + store[(svc, secret_store._chunk_count_key("tok"))] = "3" # legacy pointer + store[(svc, secret_store._legacy_chunk_key("tok", 0))] = "part0" + # chunk 1 and 2 missing + assert secret_store.get_secret("tok") is None + + def test_direct_entry_removed_after_chunked_write(self, working_keyring): + """Writing a small value then a large one leaves no direct entry.""" + _, store = working_keyring + svc = secret_store._service_name + secret_store.set_secret("tok", "small") + assert (svc, "tok") in store + + secret_store.set_secret("tok", "X" * (secret_store._CHUNK_SIZE * 2)) + assert (svc, "tok") not in store + + def test_chunk_keys_cleaned_on_revert_to_small(self, working_keyring): + """Writing a large value then a small one removes all chunk keys.""" + _, store = working_keyring + svc = secret_store._service_name + secret_store.set_secret("tok", "X" * (secret_store._CHUNK_SIZE * 2)) + assert (svc, secret_store._chunk_count_key("tok")) in store + + secret_store.set_secret("tok", "small") + assert (svc, secret_store._chunk_count_key("tok")) not in store + assert store.get((svc, "tok")) == "small" + + +# --------------------------------------------------------------------------- +# keyring-missing / file fallback path +# --------------------------------------------------------------------------- + + +class TestFallbackPath: + def test_set_writes_fallback_file(self, missing_keyring, tmp_fallback): + secret_store.set_secret("k", "v") + assert tmp_fallback.exists() + assert _slice(tmp_fallback)["k"] == "v" + + def test_set_then_get_roundtrip(self, missing_keyring, tmp_fallback): + secret_store.set_secret("k", "v") + assert secret_store.get_secret("k") == "v" + + def test_get_missing_returns_none(self, missing_keyring, tmp_fallback): + assert secret_store.get_secret("nope") is None + + @pytest.mark.skipif( + sys.platform == "win32", + reason="NTFS does not enforce POSIX permission bits via os.chmod", + ) + def test_fallback_file_is_0600(self, missing_keyring, tmp_fallback): + secret_store.set_secret("k", "v") + assert stat.S_IMODE(os.stat(tmp_fallback).st_mode) == 0o600 + + @pytest.mark.skipif( + sys.platform == "win32", + reason="NTFS does not enforce POSIX permission bits via os.chmod", + ) + def test_read_repairs_loose_permissions(self, missing_keyring, tmp_fallback): + tmp_fallback.write_text(json.dumps({"k": "v"})) + os.chmod(tmp_fallback, 0o644) + assert secret_store.get_secret("k") == "v" + assert stat.S_IMODE(os.stat(tmp_fallback).st_mode) == 0o600 + + def test_set_blank_raises(self, missing_keyring, tmp_fallback): + """Empty/whitespace-only values raise ValueError instead of silently + no-oping and emitting a misleading backend-failure warning (F8).""" + with pytest.raises(ValueError, match="non-empty"): + secret_store.set_secret("k", " ") + assert not tmp_fallback.exists() + + def test_delete_removes_from_fallback(self, missing_keyring, tmp_fallback): + secret_store.set_secret("k", "v") + secret_store.set_secret("keep", "me") + secret_store.delete_secret("k") + data = _slice(tmp_fallback) + assert "k" not in data + assert data["keep"] == "me" + + def test_corrupt_fallback_tolerated(self, missing_keyring, tmp_fallback): + tmp_fallback.write_text("{ not valid json") + assert secret_store.get_secret("k") is None + secret_store.set_secret("k", "v") + assert secret_store.get_secret("k") == "v" + + def test_fallback_warns_once(self, missing_keyring, tmp_fallback, notices): + secret_store.set_secret("k", "v") + assert sum("fallback" in m for m in notices) == 1 + # A second fallback write must not re-emit the notice (dedup). + secret_store.set_secret("k2", "v2") + assert sum("fallback" in m for m in notices) == 1 + + +# --------------------------------------------------------------------------- +# F12: null-backend silent-loss guard +# --------------------------------------------------------------------------- + + +class TestNullBackendNoSilentLoss: + """A backend that accepts-and-discards (null.Keyring, priority -1) must not + be mistaken for a successful keyring write. set_secret must route to the + hardened fallback so the credential is never silently lost. + """ + + def test_set_persists_to_fallback_not_the_void(self, null_keyring, tmp_fallback): + secret_store.set_secret("puppy_token", "REAL-TOKEN") + # The null backend's set_password is a no-op; the secret must have + # landed in the fallback file instead of vanishing. + assert tmp_fallback.exists() + assert _slice(tmp_fallback)["puppy_token"] == "REAL-TOKEN" + + def test_set_then_get_roundtrip(self, null_keyring, tmp_fallback): + secret_store.set_secret("puppy_token", "REAL-TOKEN") + assert secret_store.get_secret("puppy_token") == "REAL-TOKEN" + + def test_keyring_set_not_attempted_when_unavailable( + self, null_keyring, tmp_fallback + ): + # The availability gate must short-circuit before any keyring write, + # so the null backend's set_password is never even called. + secret_store.set_secret("puppy_token", "REAL-TOKEN") + null_keyring.set_password.assert_not_called() + + def test_emits_fallback_active_notice(self, null_keyring, tmp_fallback, notices): + secret_store.set_secret("puppy_token", "REAL-TOKEN") + assert any("fallback" in m for m in notices) + + def test_does_not_scrub_the_fresh_fallback_write(self, null_keyring, tmp_fallback): + # Regression: the old code took the keyring-success path and scrubbed + # the fallback -- guaranteeing loss. Ensure the value survives. + secret_store.set_secret("puppy_token", "REAL-TOKEN") + assert _slice(tmp_fallback).get("puppy_token") == "REAL-TOKEN" + + +class TestFailBackendFallsBack: + """The fail backend (priority 0, every op raises) is the authentic + 'no keyring on this box' case that keyring auto-selects on a headless + machine. It must route to the hardened fallback (companion to the null + guard so the two contracts stay pinned independently). + """ + + def test_set_persists_to_fallback(self, missing_keyring, tmp_fallback): + secret_store.set_secret("puppy_token", "REAL-TOKEN") + assert _slice(tmp_fallback)["puppy_token"] == "REAL-TOKEN" + assert secret_store.get_secret("puppy_token") == "REAL-TOKEN" + + +# --------------------------------------------------------------------------- +# Cross-platform fixes +# --------------------------------------------------------------------------- + + +class TestCrossPlatform: + def test_ensure_backend_survives_import_error(self): + """_ensure_backend must not crash when secret_store_backends is + unimportable (e.g. Windows where fcntl doesn't exist).""" + secret_store._backend_installed = False + with patch.dict("sys.modules", {"code_puppy.secret_store_backends": None}): + # Should complete without raising. + secret_store._ensure_backend() + assert secret_store._backend_installed is True + + @pytest.mark.skipif( + sys.platform == "win32", + reason="NTFS does not enforce POSIX permission bits via os.chmod", + ) + def test_write_fallback_uses_chmod(self, tmp_fallback): + """_write_fallback uses os.chmod (cross-platform), not os.fchmod.""" + assert secret_store._write_fallback({"k": "v"}) is True + assert json.loads(tmp_fallback.read_text()) == {"k": "v"} + mode = stat.S_IMODE(os.stat(tmp_fallback).st_mode) + assert mode == 0o600 + + +# --------------------------------------------------------------------------- +# F9 -- reserved ':cp:' namespace is enforced on caller-supplied names +# --------------------------------------------------------------------------- + + +class TestReservedNamespace: + """A caller name containing ':cp:' must be rejected before it can shadow + or destroy chunk metadata (PR #531 review finding F9).""" + + def test_set_rejects_reserved_substring(self, working_keyring): + with pytest.raises(ValueError, match="reserved substring"): + secret_store.set_secret("foo:cp:n", "3") + + def test_get_rejects_reserved_substring(self, working_keyring): + with pytest.raises(ValueError, match="reserved substring"): + secret_store.get_secret("foo:cp:0") + + def test_delete_rejects_reserved_substring(self, working_keyring): + with pytest.raises(ValueError, match="reserved substring"): + secret_store.delete_secret("foo:cp:n") + + def test_empty_name_rejected(self, working_keyring): + with pytest.raises(ValueError, match="non-empty string"): + secret_store.set_secret("", "v") + + def test_shadow_attack_cannot_poison_count_marker(self, working_keyring): + """Rejecting 'foo:cp:n' means a real 'foo' entry can't be shadowed by + a bogus chunk-count marker.""" + secret_store.set_secret("foo", "legit") + with pytest.raises(ValueError): + secret_store.set_secret("foo:cp:n", "3") + assert secret_store.get_secret("foo") == "legit" + + +# --------------------------------------------------------------------------- +# F8 -- empty value raises; whitespace-bearing values are preserved verbatim +# --------------------------------------------------------------------------- + + +class TestValueNormalization: + def test_empty_string_raises(self, working_keyring): + with pytest.raises(ValueError, match="non-empty"): + secret_store.set_secret("k", "") + + def test_whitespace_only_raises(self, working_keyring): + with pytest.raises(ValueError, match="non-empty"): + secret_store.set_secret("k", "\t \n") + + def test_surrounding_whitespace_preserved_keyring(self, working_keyring): + secret_store.set_secret("k", " tok-with-spaces ") + assert secret_store.get_secret("k") == " tok-with-spaces " + + def test_surrounding_whitespace_preserved_fallback( + self, missing_keyring, tmp_fallback + ): + secret_store.set_secret("k", " tok ") + assert secret_store.get_secret("k") == " tok " + + def test_no_false_alarm_warning_on_empty( + self, working_keyring, tmp_fallback, notices + ): + """An empty value must not reach the 'keyring write failed' path.""" + with pytest.raises(ValueError): + secret_store.set_secret("k", " ") + assert notices == [] + + +# --------------------------------------------------------------------------- +# F3/F4/F10 -- uniform fallback failure contract; callers surface failures +# --------------------------------------------------------------------------- + + +class TestFallbackFailureContract: + def test_write_fallback_returns_false_on_mkstemp_error(self, tmp_fallback): + """F3: a mkstemp failure returns False instead of raising raw OSError.""" + with patch("tempfile.mkstemp", side_effect=OSError("read-only fs")): + assert secret_store._write_fallback({"k": "v"}) is False + + def test_write_fallback_returns_false_on_replace_error(self, tmp_fallback): + with patch("os.replace", side_effect=OSError("disk full")): + assert secret_store._write_fallback({"k": "v"}) is False + + def test_set_raises_when_fallback_write_fails(self, missing_keyring, tmp_fallback): + """F4: a lost credential must not report success.""" + with patch.object(secret_store, "_write_fallback", return_value=False): + with pytest.raises(secret_store.SecretStoreError, match="NOT saved"): + secret_store.set_secret("k", "v") + + def test_delete_raises_when_scrub_write_fails(self, missing_keyring, tmp_fallback): + """F10: a failed scrub must not report a successful delete.""" + tmp_fallback.write_text(json.dumps({"k": "leftover"})) + with patch.object(secret_store, "_write_fallback", return_value=False): + with pytest.raises(secret_store.SecretStoreError, match="still be present"): + secret_store.delete_secret("k") + + def test_delete_absent_key_does_not_raise(self, missing_keyring, tmp_fallback): + """No fallback entry -> nothing to scrub -> no error even if write would fail.""" + tmp_fallback.write_text(json.dumps({"other": "keep"})) + with patch.object(secret_store, "_write_fallback", return_value=False): + secret_store.delete_secret("k") # must not raise + + +# --------------------------------------------------------------------------- +# F5 -- locked read-modify-write; F6 -- scrub stale fallback on healthy set +# --------------------------------------------------------------------------- + + +class TestFallbackLockingAndScrub: + def test_concurrent_writers_no_lost_update(self, missing_keyring, tmp_fallback): + """F5: many threads each add a distinct key; the lock must prevent + lost updates so every key survives the read-modify-write races.""" + import threading + + def writer(i): + secret_store.set_secret(f"key{i}", f"val{i}") + + threads = [threading.Thread(target=writer, args=(i,)) for i in range(25)] + for t in threads: + t.start() + for t in threads: + t.join() + + data = _slice(tmp_fallback) + for i in range(25): + assert data[f"key{i}"] == f"val{i}" + + def test_healthy_set_scrubs_fallback(self, working_keyring, tmp_fallback): + """F6: a successful keyring write removes any stale plaintext copy.""" + tmp_fallback.write_text(json.dumps({"tok": "OLD", "other": "keep"})) + secret_store.set_secret("tok", "NEW") + + # keyring now holds the new value... + assert secret_store.get_secret("tok") == "NEW" + # ...and the stale plaintext copy is gone, unrelated entries preserved. + data = _slice(tmp_fallback) + assert "tok" not in data + assert data["other"] == "keep" + + def test_healthy_set_no_resurrection(self, working_keyring, tmp_fallback): + """F6: after a healthy set scrubs the file, a vanished keyring entry + must not resurrect the old plaintext value.""" + _, store = working_keyring + tmp_fallback.write_text(json.dumps({"tok": "OLD"})) + secret_store.set_secret("tok", "NEW") + # Simulate the keyring entry vanishing (reset keychain / new profile). + store.clear() + assert secret_store.get_secret("tok") is None + + def test_scrub_failure_warns_but_does_not_raise( + self, working_keyring, tmp_fallback, notices + ): + """F6: if the stale-copy scrub can't be written, warn -- but the set + still succeeded (keyring holds the truth), so don't raise.""" + tmp_fallback.write_text(json.dumps({"tok": "OLD"})) + with patch.object(secret_store, "_write_fallback", return_value=False): + secret_store.set_secret("tok", "NEW") # must not raise + assert any("stale" in m for m in notices) + + +# --------------------------------------------------------------------------- +# F2 -- fallback file is namespaced by service; distributions stay isolated +# --------------------------------------------------------------------------- + + +class TestFallbackServiceIsolation: + def test_distributions_cannot_see_each_others_fallback( + self, missing_keyring, tmp_fallback + ): + """Distribution A's fallback secret must be invisible to distribution + B, and B must not overwrite or delete it (F2).""" + import warnings as _w + + with _w.catch_warnings(): + _w.simplefilter("ignore") + secret_store.configure_service_name("dist-a") + secret_store.set_secret("token", "A-secret") + + secret_store.configure_service_name("dist-b") + assert secret_store.get_secret("token") is None # cannot read A's + secret_store.set_secret("token", "B-secret") + secret_store.delete_secret("token") # only removes B's + + secret_store.configure_service_name("dist-a") + assert secret_store.get_secret("token") == "A-secret" # intact + + def test_fallback_file_is_service_nested(self, missing_keyring, tmp_fallback): + import warnings as _w + + with _w.catch_warnings(): + _w.simplefilter("ignore") + secret_store.configure_service_name("dist-x") + secret_store.set_secret("k", "v") + doc = json.loads(tmp_fallback.read_text()) + assert doc == {"dist-x": {"k": "v"}} + + def test_legacy_flat_file_migrated_under_default( + self, missing_keyring, tmp_fallback + ): + """A pre-scoping flat file stays readable under the default service.""" + tmp_fallback.write_text(json.dumps({"legacy": "old"})) + assert secret_store.get_service_name() == "code-puppy" + assert secret_store.get_secret("legacy") == "old" + + +# --------------------------------------------------------------------------- +# F1 -- Windows fallback hardening is honest (owner-only DACL, not chmod) +# --------------------------------------------------------------------------- + + +class TestWindowsHardening: + def test_harden_windows_invokes_icacls(self, monkeypatch, tmp_path): + """On Windows, _harden_permissions applies an owner-only DACL via + icacls rather than relying on chmod (which does nothing useful on + NTFS).""" + monkeypatch.setattr(secret_store.sys, "platform", "win32") + monkeypatch.setenv("USERNAME", "alice") + calls = [] + + def fake_run(cmd, **kw): + calls.append(cmd) + return MagicMock() + + monkeypatch.setattr(secret_store.subprocess, "run", fake_run) + p = str(tmp_path / "secrets.json") + assert secret_store._harden_permissions(p) is True + assert secret_store._windows_acl_hardened is True + assert ["icacls", p, "/inheritance:r"] in calls + assert ["icacls", p, "/grant:r", "alice:F"] in calls + + def test_harden_windows_false_when_icacls_missing(self, monkeypatch, tmp_path): + monkeypatch.setattr(secret_store.sys, "platform", "win32") + monkeypatch.setenv("USERNAME", "alice") + monkeypatch.setattr( + secret_store.subprocess, + "run", + MagicMock(side_effect=FileNotFoundError("no icacls")), + ) + assert secret_store._harden_permissions(str(tmp_path / "s.json")) is False + assert secret_store._windows_acl_hardened is False + + def test_warning_is_plaintext_honest_when_acl_fails(self, monkeypatch, notices): + """When the Windows ACL can't be applied, the notice must NOT claim + owner-only protection -- it must say the file is plaintext.""" + monkeypatch.setattr(secret_store.sys, "platform", "win32") + secret_store._windows_acl_hardened = False + secret_store._warn_fallback_active() + assert any("PLAINTEXT" in m for m in notices) + + def test_warning_states_acl_when_hardened(self, monkeypatch, notices): + monkeypatch.setattr(secret_store.sys, "platform", "win32") + secret_store._windows_acl_hardened = True + secret_store._warn_fallback_active() + assert any("NTFS ACL" in m for m in notices) + + def test_posix_uses_chmod_not_icacls(self, monkeypatch, tmp_path): + """Sanity: on POSIX the code path stays chmod-based.""" + if sys.platform == "win32": + pytest.skip("POSIX-only assertion") + called = MagicMock() + monkeypatch.setattr(secret_store.subprocess, "run", called) + p = tmp_path / "s.json" + p.write_text("{}") + assert secret_store._harden_permissions(str(p)) is True + called.assert_not_called() + assert stat.S_IMODE(os.stat(p).st_mode) == 0o600 + + +# --------------------------------------------------------------------------- +# F11 -- user-facing notices route through the messaging bus (visible in TUI) +# --------------------------------------------------------------------------- + + +class TestNotifyRouting: + def test_notify_uses_messaging_bus(self, monkeypatch): + seen = [] + import code_puppy.messaging as _msg + + monkeypatch.setattr(_msg, "emit_warning", lambda m, *a, **k: seen.append(m)) + secret_store._notify("hello human") + assert seen == ["hello human"] + + def test_notify_falls_back_to_warnings_when_bus_unavailable(self, monkeypatch): + """If the bus raises/imports fail, the notice must not be lost.""" + import code_puppy.messaging as _msg + + def boom(*a, **k): + raise RuntimeError("bus down") + + monkeypatch.setattr(_msg, "emit_warning", boom) + with pytest.warns(UserWarning, match="last resort"): + secret_store._notify("last resort notice") + + +# --------------------------------------------------------------------------- +# F7 -- generation-numbered chunking is crash-safe (old value never torn) +# --------------------------------------------------------------------------- + + +class TestGenerationChunking: + def _big(self, mult, ch="X"): + return ch * (secret_store._CHUNK_SIZE * mult + 10) + + def test_overwrite_uses_new_generation_and_gcs_old(self, working_keyring): + _, store = working_keyring + secret_store.set_secret("tok", self._big(2, "1")) # gen1, 3 chunks + gen1 = _pointer(store, "tok")[0] + assert gen1 is not None + + secret_store.set_secret("tok", self._big(3, "2")) # gen2, 4 chunks + gen2 = _pointer(store, "tok")[0] + assert gen2 is not None and gen2 != gen1 + assert secret_store.get_secret("tok") == self._big(3, "2") + # Old generation swept; only the new generation's 4 chunks remain. + assert len(_chunk_entries(store, "tok")) == 4 + + def test_crash_before_pointer_flip_keeps_old_value(self, working_keyring): + """The core F7 guarantee: if the commit (pointer flip) fails mid-write, + the previously committed value is still fully readable -- no torn or + 'Frankenstein' value.""" + big1 = self._big(2, "1") + secret_store.set_secret("tok", big1) + orig = secret_store._kr_set_raw + + def flaky(name, value): + if name.endswith(secret_store._COUNT_SUFFIX): + return False # simulate a crash exactly at the commit point + return orig(name, value) + + with patch.object(secret_store, "_kr_set_raw", side_effect=flaky): + assert secret_store._keyring_set("tok", self._big(3, "2")) is False + + # Old value intact and reassembles correctly. + assert secret_store.get_secret("tok") == big1 + + def test_crash_before_flip_leaves_no_orphan_after_next_write(self, working_keyring): + _, store = working_keyring + secret_store.set_secret("tok", self._big(2, "1")) + orig = secret_store._kr_set_raw + + def flaky(name, value): + if name.endswith(secret_store._COUNT_SUFFIX): + return False + return orig(name, value) + + with patch.object(secret_store, "_kr_set_raw", side_effect=flaky): + secret_store._keyring_set("tok", self._big(3, "2")) + # The failed write rolled back its own generation's chunks. + assert len(_chunk_entries(store, "tok")) == 3 # only the committed gen1 + + def test_legacy_chunked_value_readable(self, working_keyring): + """A value written under the pre-generation layout is still readable.""" + _, store = working_keyring + svc = secret_store._service_name + store[(svc, secret_store._chunk_count_key("tok"))] = "2" # bare count + store[(svc, secret_store._legacy_chunk_key("tok", 0))] = "AAA" + store[(svc, secret_store._legacy_chunk_key("tok", 1))] = "BBB" + assert secret_store.get_secret("tok") == "AAABBB" + + def test_overwrite_of_legacy_cleans_old_chunks(self, working_keyring): + _, store = working_keyring + svc = secret_store._service_name + store[(svc, secret_store._chunk_count_key("tok"))] = "2" + store[(svc, secret_store._legacy_chunk_key("tok", 0))] = ( + "A" * secret_store._CHUNK_SIZE + ) + store[(svc, secret_store._legacy_chunk_key("tok", 1))] = "B" * 10 + big = self._big(2, "C") + secret_store.set_secret("tok", big) + assert secret_store.get_secret("tok") == big + assert (svc, secret_store._legacy_chunk_key("tok", 0)) not in store + assert (svc, secret_store._legacy_chunk_key("tok", 1)) not in store diff --git a/tests/test_secret_store_backends.py b/tests/test_secret_store_backends.py new file mode 100644 index 000000000..47ccad813 --- /dev/null +++ b/tests/test_secret_store_backends.py @@ -0,0 +1,170 @@ +"""Tests for code_puppy.secret_store_backends -- consolidated macOS backend. + +Covers: + 1. gating -- should_use_consolidated_backend only on darwin + default + 2. blob storage -- N secrets share one keychain item + 3. concurrency-safety invariants -- corrupt blob never silently clobbered + 4. install helper -- best-effort, never raises +""" + +import json +import sys +from unittest.mock import MagicMock, patch + +import pytest + +# secret_store_backends imports fcntl which is POSIX-only. Skip the entire +# module on Windows before attempting the import so collection doesn't crash. +if sys.platform == "win32": + pytest.skip( + "secret_store_backends uses fcntl (POSIX-only); not applicable on Windows", + allow_module_level=True, + ) + +from code_puppy import secret_store_backends as ssb # noqa: E402 + + +# --------------------------------------------------------------------------- +# Gating: should_use_consolidated_backend +# --------------------------------------------------------------------------- + + +class TestGating: + def test_false_off_darwin(self, monkeypatch): + monkeypatch.setattr(ssb.sys, "platform", "linux") + assert ssb.should_use_consolidated_backend() is False + + def test_false_when_user_pinned_backend(self, monkeypatch): + monkeypatch.setattr(ssb.sys, "platform", "darwin") + monkeypatch.setenv("PYTHON_KEYRING_BACKEND", "keyring.backends.fail.Keyring") + assert ssb.should_use_consolidated_backend() is False + + def test_true_on_darwin_with_stock_backend(self, monkeypatch): + monkeypatch.setattr(ssb.sys, "platform", "darwin") + monkeypatch.delenv("PYTHON_KEYRING_BACKEND", raising=False) + from keyring.backends import macOS as macos_backend + + with patch.object( + ssb.keyring, "get_keyring", return_value=macos_backend.Keyring() + ): + assert ssb.should_use_consolidated_backend() is True + + def test_false_when_active_backend_not_stock_macos(self, monkeypatch): + monkeypatch.setattr(ssb.sys, "platform", "darwin") + monkeypatch.delenv("PYTHON_KEYRING_BACKEND", raising=False) + with patch.object(ssb.keyring, "get_keyring", return_value=MagicMock()): + assert ssb.should_use_consolidated_backend() is False + + def test_false_when_get_keyring_raises(self, monkeypatch): + monkeypatch.setattr(ssb.sys, "platform", "darwin") + monkeypatch.delenv("PYTHON_KEYRING_BACKEND", raising=False) + with patch.object(ssb.keyring, "get_keyring", side_effect=RuntimeError("boom")): + assert ssb.should_use_consolidated_backend() is False + + +# --------------------------------------------------------------------------- +# Blob storage behavior +# --------------------------------------------------------------------------- + + +@pytest.fixture +def backend(tmp_path, monkeypatch): + """A ConsolidatedKeychainBackend whose delegate is an in-memory store.""" + monkeypatch.setattr(ssb, "CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(ssb, "_LOCK_FILE", str(tmp_path / ".secrets.lock")) + + store: dict[tuple[str, str], str] = {} + delegate = MagicMock() + delegate.get_password = MagicMock(side_effect=lambda s, a: store.get((s, a))) + delegate.set_password = MagicMock( + side_effect=lambda s, a, v: store.__setitem__((s, a), v) + ) + + with patch.object(ssb.ConsolidatedKeychainBackend, "__init__", lambda self: None): + be = ssb.ConsolidatedKeychainBackend() + be._delegate_cache = delegate + return be, store + + +class TestBlobStorage: + def test_multiple_secrets_share_one_item(self, backend): + be, store = backend + be.set_password("code-puppy", "a", "1") + be.set_password("code-puppy", "b", "2") + be.set_password("code-puppy", "c", "3") + # Exactly one keychain item exists, holding all three. + assert len(store) == 1 + blob = json.loads(next(iter(store.values()))) + assert blob == {"a": "1", "b": "2", "c": "3"} + + def test_roundtrip(self, backend): + be, _ = backend + be.set_password("code-puppy", "tok", "hunter2") + assert be.get_password("code-puppy", "tok") == "hunter2" + + def test_get_missing_returns_none(self, backend): + be, _ = backend + assert be.get_password("code-puppy", "nope") is None + + def test_delete_removes_only_that_key(self, backend): + be, store = backend + be.set_password("code-puppy", "a", "1") + be.set_password("code-puppy", "b", "2") + be.delete_password("code-puppy", "a") + blob = json.loads(next(iter(store.values()))) + assert blob == {"b": "2"} + + def test_distinct_services_get_distinct_items(self, backend): + be, store = backend + be.set_password("code-puppy", "tok", "one") + be.set_password("other-app", "tok", "two") + assert len(store) == 2 + assert be.get_password("code-puppy", "tok") == "one" + assert be.get_password("other-app", "tok") == "two" + + +class TestCorruptionSafety: + def test_corrupt_blob_raises_not_clobbers(self, backend): + be, store = backend + # Simulate an unparseable existing blob. + store[("code-puppy", ssb._BLOB_ACCOUNT)] = "{ not json" + with pytest.raises(json.JSONDecodeError): + be.set_password("code-puppy", "a", "1") + # The bad blob is left intact; no silent overwrite. + assert store[("code-puppy", ssb._BLOB_ACCOUNT)] == "{ not json" + + def test_non_object_blob_rejected(self, backend): + be, store = backend + store[("code-puppy", ssb._BLOB_ACCOUNT)] = json.dumps(["not", "a", "dict"]) + with pytest.raises(ValueError, match="not a JSON object"): + be.get_password("code-puppy", "a") + + +# --------------------------------------------------------------------------- +# install_consolidated_backend_if_appropriate +# --------------------------------------------------------------------------- + + +class TestInstall: + def test_installs_when_appropriate(self): + with ( + patch.object(ssb, "should_use_consolidated_backend", return_value=True), + patch.object(ssb.keyring, "set_keyring") as set_kr, + ): + assert ssb.install_consolidated_backend_if_appropriate() is True + set_kr.assert_called_once() + + def test_skips_when_not_appropriate(self): + with ( + patch.object(ssb, "should_use_consolidated_backend", return_value=False), + patch.object(ssb.keyring, "set_keyring") as set_kr, + ): + assert ssb.install_consolidated_backend_if_appropriate() is False + set_kr.assert_not_called() + + def test_never_raises(self): + with patch.object( + ssb, "should_use_consolidated_backend", side_effect=RuntimeError("boom") + ): + # Must swallow and report False, never propagate. + assert ssb.install_consolidated_backend_if_appropriate() is False diff --git a/uv.lock b/uv.lock index 5c13de52c..04e09e277 100644 --- a/uv.lock +++ b/uv.lock @@ -102,6 +102,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + [[package]] name = "boto3" version = "1.43.9" @@ -321,6 +330,7 @@ dependencies = [ { name = "boto3" }, { name = "httpx", extra = ["http2"] }, { name = "json-repair" }, + { name = "keyring" }, { name = "mcp" }, { name = "openai" }, { name = "pillow" }, @@ -365,6 +375,7 @@ requires-dist = [ { name = "dbos", marker = "extra == 'durable'", specifier = ">=2.11.0" }, { name = "httpx", extras = ["http2"], specifier = ">=0.24.1" }, { name = "json-repair", specifier = ">=0.46.2" }, + { name = "keyring", specifier = ">=24.0.0" }, { name = "mcp", specifier = ">=1.9.4" }, { name = "openai", specifier = ">=1.99.1" }, { name = "pillow", specifier = ">=10.0.0" }, @@ -816,6 +827,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jiter" version = "0.14.0" @@ -951,6 +1007,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "logfire-api" version = "4.33.0" @@ -1006,6 +1080,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "msal" version = "1.36.0" @@ -1611,6 +1694,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2058,6 +2150,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "shellingham" version = "1.5.4"