From 62d6a8e3c07ff3a026397e42e293095dcd31909b Mon Sep 17 00:00:00 2001 From: Kailas Mahavarkar <66670953+KailasMahavarkar@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:53:18 +0530 Subject: [PATCH] security: move all API keys out of config.json into /.env + typed env.py Root cause: tools/finetune/training_config.json had an OpenRouter API key committed inline (commit 5c54c9c in PR #93, visible on GitHub). The key `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` is public in git history. **Must be revoked out-of-band at openrouter.ai** - revoking the key is the only real mitigation; history rewrites are cosmetic after the key is indexed. New layout (all secrets via .env): /.env - raw KEY=value, gitignored /.env.example - tracked template, committed /env.py - loads .env at import, exposes typed ENV singleton. Callers do ENV["openrouter_key"] or ENV.openrouter_key. Frozen dataclass + __getitem__ so typos raise KeyError. Providers resolve keys via a pid -> ENV-field map in providers.py: _PROVIDER_KEY_MAP = {"openrouter": "openrouter_key", "local_ollama": "ollama_key"} Config files now carry zero secrets: tools/autoresearch/config.json (gitignored, key fields removed) tools/autoresearch/config.example.json (template, key fields removed) tools/finetune/training_config.json (tracked, leaked key replaced) .gitignore now excludes /.env at repo root. Full suite: 1802 passed, 101 skipped. FOLLOW-UP NEEDED FROM USER: 1. Revoke the exposed key at https://openrouter.ai/settings/keys (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) 2. Optional: git filter-repo to purge the key from all history + force-push main (cosmetic - scanners have already seen it). Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 9 ++++++++ .gitignore | 4 ++++ env.py | 31 ++++++++++++++++++++++++++ tools/autoresearch/config.example.json | 6 ++--- tools/autoresearch/providers.py | 14 +++++++----- tools/finetune/training_config.json | 2 +- 6 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 .env.example create mode 100644 env.py diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..7a49f7ab --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Copy to .env and fill in real values. .env is gitignored. +# Read by /env.py at import; exposed as ENV["openrouter_key"] / ENV.openrouter_key. + +# OpenRouter API key - paid LLM routes. https://openrouter.ai/settings/keys +OPENROUTER_API_KEY= + +# Ollama API key - any non-empty string for local. Cloud-hosted Ollama tags +# (like gemma4:31b-cloud) need a real key from https://ollama.com/settings/keys +OLLAMA_API_KEY=ollama diff --git a/.gitignore b/.gitignore index d67a67e1..83ea26dd 100755 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,7 @@ website/.cache-loader/ /bench_*.py /bonsai_*.py /test_reasoning.py + +# Secrets. .env holds API keys + local overrides. Never commit it. +# .env.example is the tracked template - copy to .env and fill in values. +/.env diff --git a/env.py b/env.py new file mode 100644 index 00000000..7b023919 --- /dev/null +++ b/env.py @@ -0,0 +1,31 @@ +"""Repo-wide env loader. Reads /.env, exposes typed ENV. + +Callers do ``ENV["openrouter_key"]`` or ``ENV.openrouter_key``. +Shell env wins over .env file. Missing .env is silent (CI / prod use +real env vars). Typos raise KeyError from __getitem__. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +_DOTENV = Path(__file__).resolve().parent / ".env" +if _DOTENV.exists(): + for _line in _DOTENV.read_text().splitlines(): + _line = _line.strip() + if _line and not _line.startswith("#") and "=" in _line: + _k, _, _v = _line.partition("=") + os.environ.setdefault(_k.strip(), _v.strip().strip('"').strip("'")) + + +@dataclass(frozen=True) +class _Env: + openrouter_key: str = os.environ.get("OPENROUTER_API_KEY", "") + ollama_key: str = os.environ.get("OLLAMA_API_KEY", "ollama") + + def __getitem__(self, k: str) -> str: + return vars(self)[k] + + +ENV = _Env() diff --git a/tools/autoresearch/config.example.json b/tools/autoresearch/config.example.json index 2f11e424..9957a96f 100644 --- a/tools/autoresearch/config.example.json +++ b/tools/autoresearch/config.example.json @@ -6,7 +6,7 @@ "providers": { "local_ollama": { "base_url": "http://localhost:11434", - "api_key": "REPLACE_WITH_OLLAMA_KEY_OR_LEAVE_EMPTY", + "env_key": "ollama_key", "is_local": true, "litellm_prefix": "ollama_chat", "models": { @@ -21,7 +21,7 @@ }, "openrouter": { "base_url": "https://openrouter.ai/api/v1", - "api_key": "REPLACE_WITH_OPENROUTER_KEY_sk-or-v1-...", + "env_key": "openrouter_key", "is_local": false, "litellm_prefix": "openrouter", "models": { @@ -45,5 +45,5 @@ "noise_tolerance": 1.02, "full_bench_repeats": 3, "attempts_per_hypothesis": 3, - "notes": "Copy to config.json and fill in api_key fields. config.json is gitignored. To swap models, edit active_model. Max LLM calls per (algo, model) = 6 hypotheses × attempts_per_hypothesis." + "notes": "Copy to config.json. API keys come from /.env (never embed them here). See /.env.example + env.py for the typed ENV loader. To swap models, edit active_model." } diff --git a/tools/autoresearch/providers.py b/tools/autoresearch/providers.py index 620602a2..252df557 100644 --- a/tools/autoresearch/providers.py +++ b/tools/autoresearch/providers.py @@ -7,9 +7,10 @@ from __future__ import annotations import json -import os from pathlib import Path +from env import ENV + CONFIG_PATH = Path(__file__).resolve().parent / "config.json" @@ -58,11 +59,12 @@ def resolve_providers( base_url = p.get("base_url", "") if not base_url: continue - api_key = ( - p.get("api_key", "") - or os.environ.get(p.get("api_key_env", ""), "") - or "ollama" - ) + # Each provider config declares `env_key` pointing at an ENV field + # (see env.py). Typed: typos raise KeyError from ENV.__getitem__. + # Missing `env_key` falls back to "ollama" (works for local Ollama + # which accepts any non-empty API key). + env_field = p.get("env_key", "") + api_key = str(ENV[env_field]) if env_field else "ollama" is_local = p.get("is_local", "localhost" in base_url or "127.0.0.1" in base_url) prefix = p.get("litellm_prefix") or ("ollama_chat" if is_local else "") available = p.get("models", {}) diff --git a/tools/finetune/training_config.json b/tools/finetune/training_config.json index 34e4183c..2871b7d1 100644 --- a/tools/finetune/training_config.json +++ b/tools/finetune/training_config.json @@ -6,7 +6,7 @@ "providers": { "openrouter": { "base_url": "https://openrouter.ai/api/v1", - "api_key": "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", + "env_key": "openrouter_key", "is_local": false, "litellm_prefix": "openrouter", "models": {