oc_chat
Loading...
Searching...
No Matches
provider_secrets.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/provider_secrets.py
4
5"""
6The provider-secrets sidecar — captured API keys, never a tracked byte.
7
8Purpose
9-------
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
16exported by hand.
17
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.
22
23Responsibilities
24----------------
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
30 variable always wins.
31
32Diagnostics
33-----------
34Domain: OC_CHAT.MAIN
35Levels:
36 L2 — lifecycle events (sidecar written, variables applied)
37 L3 — semantic details (variable NAMES, counts)
38 L4 — deep tracing
39
40Contracts
41---------
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
57 one either.
58- Only ``UPPER_SNAKE`` names reach the file — the same mechanical check
59 the lane catalogue uses to keep "names, never values" verifiable.
60
61Dependencies
62------------
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)
68"""
69from __future__ import annotations
70
71import getpass
72import json
73import os
74import re
75import secrets as _secrets
76import stat
77import subprocess
78from pathlib import Path
79from typing import Callable, Mapping, MutableMapping
80
81from oc_diagnostics import _dbg
82
83_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
84
85
86#: Where captured keys live. Override for tests / unusual layouts with
87#: ``OC_CHAT_PROVIDER_SECRETS``. Relative by design: it resolves against the
88#: launch directory exactly as ``.option_c/agent_secrets.json`` does.
89DEFAULT_SECRETS_PATH: Path = Path(
90 os.environ.get("OC_CHAT_PROVIDER_SECRETS", ".option_c/provider_secrets.json")
91)
92
93#: Owner read/write only, where the platform honours POSIX modes.
94SECRET_FILE_MODE = 0o600
95
96#: Env-var NAMES are UPPER_SNAKE — the same pattern the lane catalogue pins.
97_ENV_VAR_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
98
99#: Short bound on the git probe so a hung / networked checkout cannot wedge
100#: the wizard. A timeout is an error, and an error fails closed.
101_GIT_TIMEOUT_S = 5.0
102
103
104class SecretsPathError(ValueError):
105 """A sidecar target is not covered by gitignore — the write is refused.
106
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
109 never the value.
110 """
111
112
113class SecretsFileError(ValueError):
114 """The sidecar exists but could not be parsed.
115
116 VALIDATION_ERROR (S6 L3). The message names the PATH only — never the
117 file's content, which is by definition credential material.
118 """
119
120
121def _existing_ancestor(path: Path) -> Path:
122 """The nearest existing directory at or above *path*.
123
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.
128 """
129 candidate = path if path.is_dir() else path.parent
130 while not candidate.is_dir() and candidate != candidate.parent:
131 candidate = candidate.parent
132 return candidate
133
134
135def is_gitignored(path: str | Path) -> bool:
136 """Return whether git would ignore *path* — asked, not assumed (W1).
137
138 Fails closed: any error, timeout, missing binary, or path outside a
139 work tree returns ``False``.
140 """
141 func = "is_gitignored"
142 target = Path(path).expanduser()
143 try:
144 target = target.resolve()
145 except OSError:
146 target = target.absolute()
147 try:
148 proc = subprocess.run(
149 ["git", "check-ignore", "--quiet", "--", str(target)],
150 cwd=str(_existing_ancestor(target)),
151 capture_output=True,
152 text=True,
153 timeout=_GIT_TIMEOUT_S,
154 check=False,
155 )
156 except (OSError, subprocess.SubprocessError) as exc:
157 # SECURITY_ERROR — cannot vouch for the path, so it is not covered.
158 _dbg("OC_CHAT.MAIN", func,
159 # OCT-LINT: disable=no_hardcoded_secrets reason="Diagnostic prose recording that the gitignore canary failed closed; names a path and an exception type, carries no credential (the key never reaches this function)"
160 f"SECURITY_ERROR: check-ignore unavailable for {target}: "
161 f"{type(exc).__name__}", 1, override=True)
162 return False
163 # 0 = ignored, 1 = not ignored, anything else (128 = not a repo) = error.
164 covered = proc.returncode == 0
165 _dbg("OC_CHAT.MAIN", func, f"{target} covered={covered} rc={proc.returncode}", 3)
166 return covered
167
168
169def assert_gitignored(path: str | Path) -> Path:
170 """Return *path* when git ignores it, else raise :exc:`SecretsPathError`.
171
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.
174 """
175 target = Path(path)
176 if not is_gitignored(target):
177 raise SecretsPathError(
178 # OCT-LINT: disable=no_hardcoded_secrets reason="W1 refusal remediation prose telling the operator how to fix an uncovered path; high-entropy English, not a credential"
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."
183 )
184 return target
185
186
188 env_var: str,
189 *,
190 reader: Callable[[str], str] | None = None,
191) -> str | None:
192 """Prompt for a key with masked input; return ``None`` when cancelled.
193
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.
198 """
199 func = "capture_key"
200 prompt = f" API key for {env_var} (input hidden, Enter to cancel): "
201 read = reader if reader is not None else getpass.getpass
202 try:
203 raw = read(prompt)
204 except (EOFError, KeyboardInterrupt):
205 _dbg("OC_CHAT.MAIN", func, f"capture cancelled at prompt for {env_var}", 2)
206 return None
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)
209 return None
210 # Length only. The value itself never reaches a diagnostic channel.
211 _dbg("OC_CHAT.MAIN", func, f"captured a key for {env_var} ({len(raw.strip())} chars)", 2)
212 return raw.strip()
213
214
215def load_secrets(path: str | Path = DEFAULT_SECRETS_PATH) -> dict[str, str]:
216 """Read the sidecar as a ``{NAME: value}`` map; ``{}`` when absent.
217
218 Raises :exc:`SecretsFileError` on a corrupt or wrongly-shaped file,
219 naming the path only.
220 """
221 source = Path(path)
222 if not source.is_file():
223 return {}
224 try:
225 data = json.loads(source.read_text(encoding="utf-8"))
226 except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
227 # Deliberately NOT chaining the parse error's text: a JSON decoder is
228 # entitled to quote the offending document, and this document is a
229 # credential store.
230 raise SecretsFileError(
231 f"could not parse the provider-secrets sidecar at {source} "
232 f"({type(exc).__name__})"
233 ) from None
234 if not isinstance(data, Mapping):
235 raise SecretsFileError(
236 f"provider-secrets sidecar at {source} must contain a JSON object"
237 )
238 return {
239 str(name): str(value)
240 for name, value in data.items()
241 if isinstance(name, str) and isinstance(value, str)
242 }
243
244
245def _write_atomically(payload: Mapping[str, str], target: Path) -> None:
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)}")
250 try:
251 with tmp.open("w", encoding="utf-8", newline="\n") as handle:
252 handle.write(text)
253 handle.flush()
254 os.fsync(handle.fileno())
255 _chmod_secret(tmp)
256 os.replace(tmp, target)
257 finally:
258 # A failed publish must not leave a world-readable temp file holding
259 # the key behind in the directory.
260 if tmp.exists():
261 try:
262 tmp.unlink()
263 except OSError:
264 pass
265 _chmod_secret(target)
266
267
268def _chmod_secret(path: Path) -> None:
269 """Best-effort owner-only mode; a no-op where the platform ignores it."""
270 try:
271 path.chmod(SECRET_FILE_MODE)
272 except OSError:
273 pass
274
275
277 env_var: str,
278 value: str | None,
279 *,
280 path: str | Path = DEFAULT_SECRETS_PATH,
281) -> Path:
282 """Store *value* under *env_var* in the sidecar at *path*.
283
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.
287 """
288 func = "write_secret"
289 if not isinstance(env_var, str) or not _ENV_VAR_RE.match(env_var):
290 raise ValueError(
291 # OCT-LINT: disable=no_hardcoded_secrets reason="Validator remediation message naming a field, matching the waived catalogue.py:497 text; high-entropy English, not a credential"
292 f"provider-secrets key must be an UPPER_SNAKE env-var NAME, "
293 f"found {env_var!r}"
294 )
295 if not value or not str(value).strip():
296 raise ValueError(f"refusing to store an empty value for {env_var}")
297
298 target = assert_gitignored(path)
299 payload = load_secrets(target)
300 payload[env_var] = str(value)
301 _write_atomically(payload, target)
302 _dbg("OC_CHAT.MAIN", func,
303 f"stored {env_var} in the sidecar at {target} "
304 f"({len(payload)} variable(s) held)", 2)
305 return target
306
307
309 *,
310 path: str | Path = DEFAULT_SECRETS_PATH,
311 environ: MutableMapping[str, str] | None = None,
312) -> list[str]:
313 """Load the sidecar into the environment; return the NAMES applied.
314
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.
319 """
320 func = "apply_to_environ"
321 env = os.environ if environ is None else environ
322 try:
323 stored = load_secrets(path)
324 except SecretsFileError as exc:
325 # EXTERNAL_ERROR (S6 L2) — the file is the operator's, not ours.
326 _dbg("OC_CHAT.MAIN", func, f"EXTERNAL_ERROR: {exc}", 2, override=True)
327 return []
328
329 applied: list[str] = []
330 for name, value in stored.items():
331 if (env.get(name) or "").strip():
332 continue # a real environment variable always wins
333 env[name] = value
334 applied.append(name)
335 if applied:
336 _dbg("OC_CHAT.MAIN", func,
337 f"applied {len(applied)} sidecar variable(s): {', '.join(applied)}", 2)
338 return applied
339
340
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())
345
346
347__all__ = [
348 "DEFAULT_SECRETS_PATH",
349 "SECRET_FILE_MODE",
350 "SecretsFileError",
351 "SecretsPathError",
352 "apply_to_environ",
353 "assert_gitignored",
354 "capture_key",
355 "has_key",
356 "is_gitignored",
357 "load_secrets",
358 "write_secret",
359]
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 _write_atomically(Mapping[str, str] payload, Path target)
bool is_gitignored(str|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)