6The provider-secrets sidecar — captured API keys, never a tracked byte.
10Plan 216 P1. The onboarding wizard captures a provider API key with
11masked input and puts it in exactly one place: a gitignored JSON sidecar
12mapping env-var NAME -> value, beside the registry's
13``.option_c/agent_secrets.json``. At startup the sidecar is loaded into
14``os.environ`` for variables that are not already set, so a lane the
15operator added through the wizard behaves exactly like one whose key was
18The value never leaves this module by any channel other than
19``os.environ``: it is never printed, never logged, never passed to
20:func:`_dbg` at any level, and never interpolated into an exception
21message. Everything this module traces is a variable NAME.
25- :func:`is_gitignored` / :func:`assert_gitignored` — the W1 canary:
26 ask git, at runtime, whether a path is actually covered.
27- :func:`capture_key` — masked capture that returns ``None`` on cancel.
28- :func:`load_secrets` / :func:`write_secret` — strict read, atomic write.
29- :func:`apply_to_environ` — the startup hook; a real environment
36 L2 — lifecycle events (sidecar written, variables applied)
37 L3 — semantic details (variable NAMES, counts)
42- **No key value is ever an argument to** :func:`_dbg`, ``logging``,
43 ``print``, or an exception constructor. Messages carry the variable
44 NAME and the file PATH only. This is asserted by W2, which captures
45 all three channels at once.
46- The gitignore check FAILS CLOSED (SECURITY_ERROR, S6 L1). A git error,
47 an absent git binary, or a path outside any repository all count as
48 "not covered", because none of them can promise the file will stay out
49 of a commit. Refusing to write is recoverable; a committed key is not.
50- ``check-ignore`` treats a TRACKED file as not-ignored, which is the
51 behaviour this relies on: a path git already tracks is refused even if
52 a stale rule appears to name it.
53- :func:`load_secrets` is STRICT (a corrupt sidecar raises, naming the
54 path only) while :func:`apply_to_environ` is FORGIVING (it degrades to
55 "no variables applied"). A corrupt file must not stop OC Chat from
56 starting, but it must not be silently indistinguishable from an empty
58- Only ``UPPER_SNAKE`` names reach the file — the same mechanical check
59 the lane catalogue uses to keep "names, never values" verifiable.
63- oc_diagnostics (structured debug logging via _dbg)
64- getpass (masked key capture; stdlib, never echoes the typed value)
65- subprocess (the git check-ignore canary)
66- json (sidecar serialisation)
67- pathlib (path handling)
69from __future__
import annotations
75import secrets
as _secrets
78from pathlib
import Path
79from typing
import Callable, Mapping, MutableMapping
81from oc_diagnostics
import _dbg
83_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
89DEFAULT_SECRETS_PATH: Path = Path(
90 os.environ.get(
"OC_CHAT_PROVIDER_SECRETS",
".option_c/provider_secrets.json")
94SECRET_FILE_MODE = 0o600
97_ENV_VAR_RE = re.compile(
r"^[A-Z][A-Z0-9_]*$")
105 """A sidecar target is not covered by gitignore — the write is refused.
107 SECURITY_ERROR (S6 L1): writing a credential where git can see it is
108 not a degraded mode to carry on in. The message names the PATH and
114 """The sidecar exists but could not be parsed.
116 VALIDATION_ERROR (S6 L3). The message names the PATH only — never the
117 file's content, which is by definition credential material.
122 """The nearest existing directory at or above *path*.
124 ``git check-ignore`` must run somewhere inside a work tree, and the
125 sidecar usually does not exist yet (that is the whole point of asking
126 before writing). Walking up to something real makes the probe work for
127 a path whose parent directory has not been created.
129 candidate = path
if path.is_dir()
else path.parent
130 while not candidate.is_dir()
and candidate != candidate.parent:
131 candidate = candidate.parent
136 """Return whether git would ignore *path* — asked, not assumed (W1).
138 Fails closed: any error, timeout, missing binary, or path outside a
139 work tree returns ``False``.
141 func =
"is_gitignored"
142 target = Path(path).expanduser()
144 target = target.resolve()
146 target = target.absolute()
148 proc = subprocess.run(
149 [
"git",
"check-ignore",
"--quiet",
"--", str(target)],
153 timeout=_GIT_TIMEOUT_S,
156 except (OSError, subprocess.SubprocessError)
as exc:
158 _dbg(
"OC_CHAT.MAIN", func,
160 f
"SECURITY_ERROR: check-ignore unavailable for {target}: "
161 f
"{type(exc).__name__}", 1, override=
True)
164 covered = proc.returncode == 0
165 _dbg(
"OC_CHAT.MAIN", func, f
"{target} covered={covered} rc={proc.returncode}", 3)
170 """Return *path* when git ignores it, else raise :exc:`SecretsPathError`.
172 The canary at the heart of P1. The message is actionable — it names
173 the path and says what must be true — and carries no key material.
179 f
"refusing to write provider secrets to {target}: the path is not "
180 f
"covered by gitignore (checked with 'git check-ignore'). Add a "
181 f
"gitignore rule for it, or point the sidecar at a covered path "
182 f
"via OC_CHAT_PROVIDER_SECRETS."
190 reader: Callable[[str], str] |
None =
None,
192 """Prompt for a key with masked input; return ``None`` when cancelled.
194 A bare Enter, whitespace, Ctrl+C or EOF all mean "cancel" — the
195 wizard's cancel-safety contract (P5) starts here. *reader* is injected
196 by tests; the default is :func:`getpass.getpass`, never :func:`input`,
197 so the value is not echoed to the terminal.
200 prompt = f
" API key for {env_var} (input hidden, Enter to cancel): "
201 read = reader
if reader
is not None else getpass.getpass
204 except (EOFError, KeyboardInterrupt):
205 _dbg(
"OC_CHAT.MAIN", func, f
"capture cancelled at prompt for {env_var}", 2)
207 if raw
is None or not raw.strip():
208 _dbg(
"OC_CHAT.MAIN", func, f
"empty capture for {env_var} treated as cancel", 2)
211 _dbg(
"OC_CHAT.MAIN", func, f
"captured a key for {env_var} ({len(raw.strip())} chars)", 2)
215def load_secrets(path: str | Path = DEFAULT_SECRETS_PATH) -> dict[str, str]:
216 """Read the sidecar as a ``{NAME: value}`` map; ``{}`` when absent.
218 Raises :exc:`SecretsFileError` on a corrupt or wrongly-shaped file,
219 naming the path only.
222 if not source.is_file():
225 data = json.loads(source.read_text(encoding=
"utf-8"))
226 except (OSError, UnicodeDecodeError, json.JSONDecodeError)
as exc:
231 f
"could not parse the provider-secrets sidecar at {source} "
232 f
"({type(exc).__name__})"
234 if not isinstance(data, Mapping):
236 f
"provider-secrets sidecar at {source} must contain a JSON object"
239 str(name): str(value)
240 for name, value
in data.items()
241 if isinstance(name, str)
and isinstance(value, str)
246 """Temp-then-rename write with a restrictive mode applied before publish."""
247 target.parent.mkdir(parents=
True, exist_ok=
True)
248 text = json.dumps(dict(sorted(payload.items())), indent=2, sort_keys=
True) +
"\n"
249 tmp = target.with_name(f
".tmp-{target.name}-{_secrets.token_hex(8)}")
251 with tmp.open(
"w", encoding=
"utf-8", newline=
"\n")
as handle:
254 os.fsync(handle.fileno())
256 os.replace(tmp, target)
269 """Best-effort owner-only mode; a no-op where the platform ignores it."""
271 path.chmod(SECRET_FILE_MODE)
280 path: str | Path = DEFAULT_SECRETS_PATH,
282 """Store *value* under *env_var* in the sidecar at *path*.
284 Refuses any target git does not ignore (W1) BEFORE touching the disk,
285 and refuses a name that is not an UPPER_SNAKE env-var name. Existing
286 entries are preserved; the named one is replaced.
288 func =
"write_secret"
289 if not isinstance(env_var, str)
or not _ENV_VAR_RE.match(env_var):
292 f
"provider-secrets key must be an UPPER_SNAKE env-var NAME, "
295 if not value
or not str(value).strip():
296 raise ValueError(f
"refusing to store an empty value for {env_var}")
300 payload[env_var] = str(value)
302 _dbg(
"OC_CHAT.MAIN", func,
303 f
"stored {env_var} in the sidecar at {target} "
304 f
"({len(payload)} variable(s) held)", 2)
310 path: str | Path = DEFAULT_SECRETS_PATH,
311 environ: MutableMapping[str, str] |
None =
None,
313 """Load the sidecar into the environment; return the NAMES applied.
315 A variable already carrying a non-blank value is left ALONE — a real
316 environment variable always wins over the sidecar (P1). Never raises:
317 a corrupt or unreadable sidecar degrades to "nothing applied" so a bad
318 file cannot stop OC Chat from starting.
320 func =
"apply_to_environ"
321 env = os.environ
if environ
is None else environ
324 except SecretsFileError
as exc:
326 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: {exc}", 2, override=
True)
329 applied: list[str] = []
330 for name, value
in stored.items():
331 if (env.get(name)
or "").strip():
336 _dbg(
"OC_CHAT.MAIN", func,
337 f
"applied {len(applied)} sidecar variable(s): {', '.join(applied)}", 2)
341def has_key(env_var: str, environ: Mapping[str, str] |
None =
None) -> bool:
342 """Whether *env_var* currently carries a non-blank value (presence only)."""
343 env = os.environ
if environ
is None else environ
344 return bool((env.get(env_var)
or "").strip())
348 "DEFAULT_SECRETS_PATH",
str|None capture_key(str env_var, *, Callable[[str], str]|None reader=None)
Path assert_gitignored(str|Path path)
list[str] apply_to_environ(*, str|Path path=DEFAULT_SECRETS_PATH, MutableMapping[str, str]|None environ=None)
bool has_key(str env_var, Mapping[str, str]|None environ=None)
None _chmod_secret(Path path)
None _write_atomically(Mapping[str, str] payload, Path target)
bool is_gitignored(str|Path path)
Path _existing_ancestor(Path path)
dict[str, str] load_secrets(str|Path path=DEFAULT_SECRETS_PATH)
Path write_secret(str env_var, str|None value, *, str|Path path=DEFAULT_SECRETS_PATH)