Plugin Secret Store

CR-12: project-local secret storage for API-based plugins (file-backed, 0600)

SecretStore Protocol

Interface any secret backend must satisfy. Distinct from PluginConfigStore: secret values are never persisted in the config DB, never echoed in config_schema, and never logged. The substrate resolves a plugin’s required secrets from a SecretStore and injects them into the worker subprocess env at spawn (CR-12); plugin SDKs read them from their own process env.

The scope parameter is the reserved multi-user seam: the shipped LocalSecretStore ignores it (single-user), while a future per-user / group store uses it to isolate one principal’s keys from another’s. Same activation-seam pattern as the host’s set_session_id and CR-2’s workflow-scoped config store.


SecretStore


def SecretStore(
    args:VAR_POSITIONAL, kwargs:VAR_KEYWORD
):

Protocol for resolving per-plugin secrets (API keys, tokens).

LocalSecretStore

Substrate-shipped default. JSON file at <secrets_dir>/secrets.json, with the directory created 0700 and the file 0600. secrets_dir defaults to ~/.cjm/secrets (bootstrap fallback); PluginManager wires the project-local cfg.data_dir / "secrets" so secrets live alongside the rest of a project’s substrate data (e.g. my-plugin/.cjm/secrets/).

Values are plaintext JSON — encryption-at-rest is a deferred CR-12 follow-up; 0600 is the honest single-user baseline. The on-disk shape folds the multi-user scope dimension so the file format needs no migration when a scoped store lands:

{"<scope-or-__default__>": {"<plugin_name>": {"<key>": "<value>"}}}

Keyring / multi-user / workflow-scoped backends implement the same Protocol and are injected via DI.


LocalSecretStore


def LocalSecretStore(
    secrets_dir:Optional=None, # Directory for secrets.json; None -> ~/.cjm/secrets
):

File-backed default SecretStore (0600 JSON under secrets_dir).


LocalSecretStore.get_secret


def get_secret(
    plugin_name:str, # Plugin the secret belongs to
    key:str, # Secret key (typically the env-var name, e.g. GEMINI_API_KEY)
    scope:Optional=None, # Reserved multi-user seam; ignored by the local store
)->Optional: # The secret value, or None if absent

Resolve a secret value.


LocalSecretStore.set_secret


def set_secret(
    plugin_name:str, # Plugin the secret belongs to
    key:str, # Secret key
    value:str, # Secret value (stored plaintext at 0600)
    scope:Optional=None, # Reserved multi-user seam
)->None:

Persist a secret value.


LocalSecretStore.delete_secret


def delete_secret(
    plugin_name:str, # Plugin the secret belongs to
    key:str, # Secret key
    scope:Optional=None, # Reserved multi-user seam
)->bool: # True if a secret was removed

Remove a secret, pruning now-empty plugin/scope containers.


LocalSecretStore.list_keys


def list_keys(
    plugin_name:str, # Plugin to list secrets for
    scope:Optional=None, # Reserved multi-user seam
)->List: # Secret key NAMES (never values)

Return the names of secrets stored for a plugin (never the values).

# CR-12: LocalSecretStore satisfies the Protocol; round-trips secrets; enforces
# 0700 dir / 0600 file perms; list_keys never leaks values; scope folds into the
# on-disk structure without affecting the (single-user) local store's default scope.
import tempfile, os as _os, stat as _stat, shutil as _shutil
from pathlib import Path as _Path

_tmp = tempfile.mkdtemp()
try:
    store = LocalSecretStore(_Path(_tmp) / "secrets")
    assert isinstance(store, SecretStore)

    # empty reads
    assert store.get_secret("gemini", "GEMINI_API_KEY") is None
    assert store.list_keys("gemini") == []
    assert store.delete_secret("gemini", "GEMINI_API_KEY") is False

    # round-trip
    store.set_secret("gemini", "GEMINI_API_KEY", "sk-abc123")
    assert store.get_secret("gemini", "GEMINI_API_KEY") == "sk-abc123"
    assert store.list_keys("gemini") == ["GEMINI_API_KEY"]

    # list_keys returns NAMES only (no value leak)
    store.set_secret("gemini", "OTHER", "v")
    assert store.list_keys("gemini") == ["GEMINI_API_KEY", "OTHER"]
    assert "sk-abc123" not in store.list_keys("gemini")

    # perms: dir 0700, file 0600
    assert _stat.S_IMODE(_os.stat(store.secrets_dir).st_mode) == 0o700
    assert _stat.S_IMODE(_os.stat(store.path).st_mode) == 0o600

    # delete prunes
    assert store.delete_secret("gemini", "OTHER") is True
    assert store.list_keys("gemini") == ["GEMINI_API_KEY"]

    # scope folds into structure; named scope isolated from default scope
    store.set_secret("gemini", "GEMINI_API_KEY", "scoped", scope="alice")
    assert store.get_secret("gemini", "GEMINI_API_KEY", scope="alice") == "scoped"
    assert store.get_secret("gemini", "GEMINI_API_KEY") == "sk-abc123"  # default unaffected

    print("CR-12 LocalSecretStore round-trip + perms + scope-fold: PASS")
finally:
    _shutil.rmtree(_tmp, ignore_errors=True)
CR-12 LocalSecretStore round-trip + perms + scope-fold: PASS