diff --git a/src/adapters/ai_analyst.py b/src/adapters/ai_analyst.py index 138d6f0..121f241 100644 --- a/src/adapters/ai_analyst.py +++ b/src/adapters/ai_analyst.py @@ -53,6 +53,11 @@ class _FallbackOpenAIError(Exception): def build_deepseek_client(*, api_key: str, base_url: str) -> AsyncOpenAI: + if not api_key: + raise ValueError( + "AI API key is empty. " + "Set OSINT_D2_AI_API_KEY via environment variable, .env file, or run: osint-d2 doctor setup-ai" + ) return AsyncOpenAI(api_key=api_key, base_url=base_url) diff --git a/src/cli/doctor.py b/src/cli/doctor.py index da375df..e856a8d 100644 --- a/src/cli/doctor.py +++ b/src/cli/doctor.py @@ -60,7 +60,11 @@ def run() -> None: if bool(settings.ai_api_key): table.add_row("AI key", "OK", "Remote AI enabled") else: - table.add_row("AI key", "OPTIONAL", "No key set -> heuristic analysis fallback") + table.add_row( + "AI key", + "[yellow]MISSING[/yellow]", + "No key → heuristic fallback. Agent mode (--agent) will fail.", + ) table.add_row("AI base_url", "OK", settings.ai_base_url) table.add_row("AI model", "OK", settings.ai_model) diff --git a/src/core/services/agent_engine.py b/src/core/services/agent_engine.py index 6278d9e..5ae0c78 100644 --- a/src/core/services/agent_engine.py +++ b/src/core/services/agent_engine.py @@ -124,6 +124,12 @@ async def run( ) -> AgentResult: """Run the agent loop until it calls generate_report or exhausts steps.""" + if not self._settings.ai_api_key: + raise ValueError( + "OSINT_D2_AI_API_KEY is not configured. " + "Set it via environment variable, .env file, or run: osint-d2 doctor setup-ai" + ) + client = AsyncOpenAI( api_key=self._settings.ai_api_key, base_url=self._settings.ai_base_url, diff --git a/tests/test_agent_engine.py b/tests/test_agent_engine.py index 892dc4e..3bd53b1 100644 --- a/tests/test_agent_engine.py +++ b/tests/test_agent_engine.py @@ -258,3 +258,50 @@ def test_callback_receives_step(self): engine._on_step(step) # type: ignore[misc] assert len(captured) == 1 assert captured[0].tool_name == "scan_username" + + +# --------------------------------------------------------------------------- +# API key validation (issue #33) +# --------------------------------------------------------------------------- + +class TestApiKeyValidation: + """Verify fail-fast when ai_api_key is None — prevents silent SDK fallback + to OPENAI_API_KEY env var which would misdirect credentials.""" + + @pytest.mark.asyncio + async def test_agent_engine_raises_without_api_key(self): + """AgentEngine.run() must raise ValueError before any LLM call.""" + settings = AppSettings( + ai_api_key=None, + ai_base_url="https://api.deepseek.com", + ) + engine = AgentEngine(settings=settings) + + with pytest.raises(ValueError, match="OSINT_D2_AI_API_KEY"): + await engine.run("investigate testuser") + + @pytest.mark.asyncio + async def test_agent_engine_raises_with_empty_string_key(self): + """Empty string should also be caught.""" + settings = AppSettings( + ai_api_key="", + ai_base_url="https://api.deepseek.com", + ) + engine = AgentEngine(settings=settings) + + with pytest.raises(ValueError, match="OSINT_D2_AI_API_KEY"): + await engine.run("investigate testuser") + + def test_build_deepseek_client_raises_on_empty_key(self): + """build_deepseek_client must fail-fast on empty key.""" + from adapters.ai_analyst import build_deepseek_client + + with pytest.raises(ValueError, match="AI API key is empty"): + build_deepseek_client(api_key="", base_url="https://api.deepseek.com") + + def test_build_deepseek_client_works_with_valid_key(self): + """Valid key should construct the client without error.""" + from adapters.ai_analyst import build_deepseek_client + + client = build_deepseek_client(api_key="sk-test-key", base_url="https://api.deepseek.com") + assert client is not None