feat(privacy): blocking consent gate for env-fallback LLM API keys

Adds api_key_source provenance ('flag' | 'env' | None) to LLMProvider
so cmd_init can distinguish a key passed via --llm-api-key (explicit
opt-in) from one silently picked up via OPENAI_API_KEY / ANTHROPIC_API_KEY
shell env (stray credential).

When the endpoint is external AND api_key_source == 'env', init now
prints a blocking [y/N] prompt before any data is sent. Anything other
than 'y' drops the LLM and falls back to heuristics-only.

Adds --accept-external-llm flag for CI / non-interactive bypass.

Completes the UX gap in #1224: the URL-based warning was informational
and init kept running, so a user who didn't notice the line had already
leaked. The consent prompt is the actual gate; explicit flag-passed keys
remain treated as already-consented.
This commit is contained in:
MSL
2026-04-27 00:44:57 -07:00
parent 899a5ec4c6
commit 72cbfb5967
4 changed files with 316 additions and 4 deletions
+30 -4
View File
@@ -127,11 +127,18 @@ class LLMProvider:
endpoint: Optional[str] = None,
api_key: Optional[str] = None,
timeout: int = 120,
api_key_source: Optional[str] = None,
):
self.model = model
self.endpoint = endpoint
self.api_key = api_key
self.timeout = timeout
# Provenance of api_key (issue #26): "flag" when the constructor
# received an explicit api_key arg, "env" when it fell back to an
# environment variable, None when no key is in play. cmd_init
# uses this to gate the consent prompt — stray env-resolved keys
# require explicit user confirmation.
self.api_key_source = api_key_source
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
raise NotImplementedError
@@ -253,8 +260,20 @@ class OpenAICompatProvider(LLMProvider):
timeout: int = 120,
**_: object,
):
resolved_key = api_key or os.environ.get("OPENAI_API_KEY")
super().__init__(model=model, endpoint=endpoint, api_key=resolved_key, timeout=timeout)
if api_key:
resolved_key = api_key
source: Optional[str] = "flag"
else:
env_key = os.environ.get("OPENAI_API_KEY")
resolved_key = env_key or None
source = "env" if env_key else None
super().__init__(
model=model,
endpoint=endpoint,
api_key=resolved_key,
timeout=timeout,
api_key_source=source,
)
def _resolve_url(self) -> str:
if not self.endpoint:
@@ -321,12 +340,19 @@ class AnthropicProvider(LLMProvider):
timeout: int = 120,
**_: object,
):
key = api_key or os.environ.get("ANTHROPIC_API_KEY")
if api_key:
resolved_key = api_key
source: Optional[str] = "flag"
else:
env_key = os.environ.get("ANTHROPIC_API_KEY")
resolved_key = env_key or None
source = "env" if env_key else None
super().__init__(
model=model,
endpoint=endpoint or self.DEFAULT_ENDPOINT,
api_key=key,
api_key=resolved_key,
timeout=timeout,
api_key_source=source,
)
def check_available(self) -> tuple[bool, str]: