Repository navigation
security(ai): FLUID_LLM_API_KEY only for a named provider; saved keys stay off project hosts - #853
Merged
Merged
Conversation
FLUID_LLM_API_KEY does not say which vendor it is for, yet forge sent it to whatever provider it picked, including one it only inferred: a running Ollama or the one OLLAMA_HOST names (as a bearer token, in cleartext over http), the provider of the one keyring key, or the provider of the one key variable set. So an OpenAI key could reach a local or remote Ollama, or Anthropic. The key is now used only when --llm-provider or FLUID_LLM_PROVIDER names the provider the run uses (founder decision, 2026-11-12). A provider forge picks itself gets its own key variable, then its keyring key, and a warning says the generic key was not used. Interactive fluid forge and fluid init record what the operator named before their setup replaces args.llm_provider, so setup's pick counts as picked. fluid ai test reads the key only with --llm-provider, fluid doctor only with FLUID_LLM_PROVIDER. A project .env can no longer pair an OpenAI, Anthropic, Gemini or GitHub endpoint with FLUID_LLM_API_KEY: forge can pick those providers without a name, and then the operator's keyring key, not the project's key, would go out.
…host A project .env may point a provider at its own host (OPENAI_BASE_URL, ANTHROPIC_BASE_URL, GEMINI_API_BASE, GITHUB_API_BASE) next to a key of its own; credentials/protected_env pairs it because that key is what the readers send. Interactive fluid forge and fluid init then loaded the provider that fluid ai setup saved and copied its keyring key into the provider's key variable with set_session_env, over the project's key (for Gemini into GOOGLE_API_KEY, which forge reads before GEMINI_API_KEY). The operator's key went to the project's host. set_session_env now leaves the variable alone while a project endpoint of that provider is in effect (dotenv_store.project_endpoints_in_effect, as the Snowflake readers already do), so the project's own key goes there, and logs that the operator's key was not used. fluid ai setup checked a typed or imported key with one litellm call, which reads the endpoint from the environment, so the check sent that key to the project's host too. While such an endpoint is in effect, setup now asks for no key, skips a key it would import, and _validate_api_key sends nothing. For a provider without a mapped group, any LLM endpoint a project file set counts.
CHANGELOG: keep main's [Unreleased] order (Added, Changed, Fixed) and add this branch's Security section after it.
CodeQL (py/clear-text-logging-sensitive-data) flagged the two warnings this branch adds: - resolve_llm_config logged a note unpacked from the same tuple as the API key, returned by _api_key_for_run, a call whose name reads as a password source. The note now comes from _unnamed_provider_note, from the provider names and the named flag alone; the key comes from _resolve_api_key on its own. - set_session_env's warning logged the provider string, which setup can get by classifying a typed key (_classify_key_shape -> detect_provider_from_ api_key). The notice now names the endpoint group from _PROVIDER_ENDPOINT_SERVICES, picked by comparison so it is the table's own constant, and the endpoint variables; the provider string is never logged.
…ope tests Addresses the two code-quality review threads on #853: - the copilot_missing_llm_api_key suggestion this branch added was two adjacent string literals in a list (implicit concatenation, read as a possibly missing comma); it is one literal now; - tests/test_llm_api_key_scope.py imported fluid_build.llm.providers both as a module and with from-imports; it uses the module import only, and the new interactive-setup matrix test does the same.
#854 walks _provider_name_from and the saved fluid ai setup choice in check_llm_readiness. There FLUID_LLM_API_KEY counts only for a named provider (_provider_is_named(None, env, ...)), never for the saved choice. resolve_llm_config keeps #854's 'not saved' auto-pick condition and this branch's unnamed-provider note.
…airing matrix The file imported the module as llm_providers in two places and with 'from ... import LlmConfig' in _run_codex (github-code-quality on #853). _run_codex now uses llm_providers.LlmConfig as well.
This was referenced Nov 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Two fixes for API keys going to the wrong host. Both came out of the security review of #852 and were decided by the founder on 2026-11-12. There is one commit for each.
1.
FLUID_LLM_API_KEYgoes only to a provider you name.The key does not say which vendor it is for. Even so, forge sent it to whatever provider it picked, including one it only inferred:
OLLAMA_HOSTnames, asAuthorization: Bearer(in cleartext when the host uses http);So an OpenAI key could reach a local or remote Ollama, or Anthropic.
The key is now used only when
--llm-providerorFLUID_LLM_PROVIDERnames the provider the run uses. A provider forge picks itself gets its own key variable, then its keyring key, and a warning says the generic key was not used.fluid forgeandfluid init: setup replacesargs.llm_provider, so each first records what the operator named (remember_named_llm_provider). Setup's own pick therefore counts as picked, not named.fluid ai testuses the key only with--llm-provider.fluid doctorcounts it only withFLUID_LLM_PROVIDER.Because forge can pick OpenAI, Anthropic, Gemini and GitHub without a name, a project
.envcan no longer pair their endpoints withFLUID_LLM_API_KEY(credentials/protected_env.py). On such a run the operator's keyring key, not the project's key, would go to the project's host. Endpoints of providers forge reaches only by name (groq, deepseek, Azure, Bedrock, Vertex, and the unlisted litellm names) still pair with it.2. Interactive setup keeps your saved key off a project's host.
A project
.envmay point a provider at its own host (OPENAI_BASE_URL…) next to a key of its own. That pairing is allowed because the project's key is what goes there.Before this change:
fluid ai setupsaved and copied its keyring key over the project's key (set_session_env). For Gemini it copied the key intoGOOGLE_API_KEY, which forge reads first. Your key went to the project's host.fluid ai setupchecked a typed or imported key with one litellm call, which reads that endpoint from the environment.Now, while a project endpoint of that provider is in effect:
_validate_api_keysends nothing;This reuses
dotenv_store.project_endpoints_in_effect, the check the Snowflake readers already use.Command Center is unaffected.
fluid_cli_service.forge_from_intentalways passes--llm-providerplus the provider's own key variable, andFLUID_LLM_API_KEYappears nowhere in CC (checked at itsmain, 695f4f9a).No issue: these are follow-ups from the #852 security review.
Type of Change
FLUID_LLM_API_KEYno longer reaches a provider forge inferred. Name it with--llm-provider <name>orFLUID_LLM_PROVIDER=<name>..envOpenAI, Anthropic, Gemini or GitHub endpoint needs that provider's own key variable.Documentation
Docs PR linked:
No docs needed — this change does not affect user-facing behaviour
Docs TODO — I know docs are needed and will create a PR in forge_docs before this is merged
Pages in forge_docs:
docs/advanced/environment-variables.md:FLUID_LLM_API_KEYapplies only to a named provider.docs/advanced/llm-providers.md: the key resolution order.docs/advanced/project-dotenv-trust.md: the pairing table (OpenAI, Anthropic, Gemini and GitHub endpoints need the provider's own key), and setup never copies a saved key over a project's.docs/advanced/production-troubleshooting.md: the "FLUID_LLM_API_KEY is set but not used" warning, and setup's "Not asking for a … key".docs/cli/ai.md:fluid ai setupinside a project with its own LLM endpoint, andfluid ai testusing the generic key only with--llm-provider.docs/cli/doctor.md: readiness counts the generic key only withFLUID_LLM_PROVIDER.docs/upgrading.md: the behaviour change, and how to name the provider.This repo's legacy
docs/ENVIRONMENT_VARIABLES.mdis now stale: the OpenAI, Gemini and GitHub Models rows of its pairing table, and the resolve order on line 194. I left it untouched, because docs live in forge_docs.Checklist
pytest). Locally, only the shared venv's baseline fails, and it fails the same way onmain; see Testing.ruff checkandblackwith no errorspython scripts/add_license_headers.py;examples/**exempt)Testing
New tests.
tests/test_llm_api_key_scope.py: 22 tests, hermetic (the keyring, the Ollama probe, the savedfluid ai setupconfig and HOME are patched).main(3900c45), 17 of them fail on their assertions. The key reaches Ollama, the keyring's provider and the key variable's provider.POST /api/generate.tests/test_dotenv_pairing_matrix.py:/chat/completions), Anthropic (/v1/messages) and Gemini (generateContent); a typed key reaches it for OpenAI and groq; and setup asks three times for a key it cannot check.Full suite with CI's flags (
-n auto --dist loadscope --ignore=tests/perf), on head 266ca78: 34,550 passed, 8 failed, 2 errors. The failures matchmainin the same venv: the same 8 tests fail there, and the 2 errors areNo module named 'bashlex'.CodeQL fix (bcc5eb5). CodeQL raised 3
py/clear-text-logging-sensitive-dataalerts (#345-#347) on the two warnings this branch adds:resolve_llm_config's note was unpacked from the same tuple as the key, returned by a call whose name reads as a password source. It is now built by_unnamed_provider_notefrom the provider names and the named flag only.set_session_env's warning logged the provider string, which setup can get by classifying a typed key. It now logs the endpoint group, a constant from_PROVIDER_ENDPOINT_SERVICESpicked by comparison, and the endpoint variable names.After the fix the lint gates and 2,415 related tests pass; the only failures are the known
mainones.Merge of
main.main(#851, the Agent Skills pack) was merged in as 36ac284. The only conflict was the CHANGELOG: it keepsmain's[Unreleased]order and adds this branch's Security section after it. On the merged tree I re-ran all the lint gates, the related suites and #851's tests: 2,415 passed, and the only failures are the knownmainones. Perf budgets: 41 passed and 7 xfailed, with the same module counts asmain.Lint:
Live, through the CLI.
fluid forge data-model from-intent intent.yaml --llm-model llama3.2, withOLLAMA_HOSTat a loopback stub that records headers andFLUID_LLM_API_KEYset. The run usedenv -i, a temp HOME, the null keyring, and other egress proxied to a dead port.main--llm-provider ollamaReviews.
Screenshots / Output
Older problems, not in this PR:
test_ai_setup.py::TestModelCatalogIntegrity::test_class_defaults_match_catalog_flagshipfails when it runs aftertest_dotenv_pairing_matrix.pyin one process, onmaintoo._offer_import_env_api_key) can save a key that came from a project.envinto the operator's keyring.fluid forgeignores--llm-provider/FLUID_LLM_PROVIDERwhen setup finds a provider.GITHUB_API_KEY.fluid ai testignoresFLUID_LLM_PROVIDER.