personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Strip provider API keys from cogitate CLI subprocess environment

Cogitate agents spawning CLI tools (claude, codex, gemini) inherited
API keys from .env, bypassing each CLI's native platform auth. Now
build_cogitate_env() strips the provider's key by default so CLIs use
their own account-based auth. Configurable per-provider via
providers.auth in journal config (default: "platform", opt-in: "api_key").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+156
+98
tests/test_cli_provider.py
··· 4 4 """Tests for think.providers.cli — CLI subprocess runner infrastructure.""" 5 5 6 6 import asyncio 7 + import os 7 8 from unittest.mock import AsyncMock, patch 8 9 9 10 import pytest ··· 12 13 CLIRunner, 13 14 ThinkingAggregator, 14 15 assemble_prompt, 16 + build_cogitate_env, 15 17 ) 16 18 from think.providers.shared import JSONEventCallback, safe_raw 17 19 ··· 437 439 assert result[0] == {"type": "msg"} 438 440 assert result[1] == {"type": "msg"} 439 441 assert "_raw_trimmed" in result[2] 442 + 443 + 444 + # --------------------------------------------------------------------------- 445 + # build_cogitate_env 446 + # --------------------------------------------------------------------------- 447 + 448 + 449 + class TestBuildCogitateEnv: 450 + """Tests for build_cogitate_env — API key stripping for CLI subprocesses.""" 451 + 452 + def test_default_strips_key(self): 453 + """No auth config → default platform mode → key removed.""" 454 + config = {"providers": {}} 455 + with ( 456 + patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-secret"}, clear=False), 457 + patch("think.utils.get_config", return_value=config), 458 + ): 459 + env = build_cogitate_env("ANTHROPIC_API_KEY") 460 + assert "ANTHROPIC_API_KEY" not in env 461 + 462 + def test_explicit_platform_strips_key(self): 463 + """auth.anthropic = "platform" → key removed.""" 464 + config = {"providers": {"auth": {"anthropic": "platform"}}} 465 + with ( 466 + patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-secret"}, clear=False), 467 + patch("think.utils.get_config", return_value=config), 468 + ): 469 + env = build_cogitate_env("ANTHROPIC_API_KEY") 470 + assert "ANTHROPIC_API_KEY" not in env 471 + 472 + def test_api_key_mode_preserves_key(self): 473 + """auth.anthropic = "api_key" → key preserved.""" 474 + config = {"providers": {"auth": {"anthropic": "api_key"}}} 475 + with ( 476 + patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-secret"}, clear=False), 477 + patch("think.utils.get_config", return_value=config), 478 + ): 479 + env = build_cogitate_env("ANTHROPIC_API_KEY") 480 + assert env["ANTHROPIC_API_KEY"] == "sk-secret" 481 + 482 + def test_missing_auth_section_strips_key(self): 483 + """No providers section at all → safe default, key removed.""" 484 + config = {} 485 + with ( 486 + patch.dict(os.environ, {"OPENAI_API_KEY": "sk-openai"}, clear=False), 487 + patch("think.utils.get_config", return_value=config), 488 + ): 489 + env = build_cogitate_env("OPENAI_API_KEY") 490 + assert "OPENAI_API_KEY" not in env 491 + 492 + def test_other_env_vars_preserved(self): 493 + """Non-API-key vars are never stripped.""" 494 + config = {"providers": {}} 495 + with ( 496 + patch.dict( 497 + os.environ, 498 + {"ANTHROPIC_API_KEY": "sk-secret", "HOME": "/home/test"}, 499 + clear=False, 500 + ), 501 + patch("think.utils.get_config", return_value=config), 502 + ): 503 + env = build_cogitate_env("ANTHROPIC_API_KEY") 504 + assert env["HOME"] == "/home/test" 505 + 506 + def test_key_not_in_env_is_harmless(self): 507 + """Stripping a key that doesn't exist doesn't error.""" 508 + config = {"providers": {}} 509 + with ( 510 + patch.dict(os.environ, {}, clear=False), 511 + patch("think.utils.get_config", return_value=config), 512 + ): 513 + env = build_cogitate_env("GOOGLE_API_KEY") 514 + assert "GOOGLE_API_KEY" not in env 515 + 516 + def test_per_provider_independence(self): 517 + """Each provider's auth mode is independent.""" 518 + config = { 519 + "providers": { 520 + "auth": { 521 + "anthropic": "api_key", 522 + "openai": "platform", 523 + } 524 + } 525 + } 526 + with ( 527 + patch.dict( 528 + os.environ, 529 + {"ANTHROPIC_API_KEY": "sk-ant", "OPENAI_API_KEY": "sk-oai"}, 530 + clear=False, 531 + ), 532 + patch("think.utils.get_config", return_value=config), 533 + ): 534 + ant_env = build_cogitate_env("ANTHROPIC_API_KEY") 535 + oai_env = build_cogitate_env("OPENAI_API_KEY") 536 + assert ant_env["ANTHROPIC_API_KEY"] == "sk-ant" 537 + assert "OPENAI_API_KEY" not in oai_env
+2
think/providers/anthropic.py
··· 50 50 CLIRunner, 51 51 ThinkingAggregator, 52 52 assemble_prompt, 53 + build_cogitate_env, 53 54 check_cli_binary, 54 55 ) 55 56 from .shared import ( ··· 269 270 translate=translate, 270 271 callback=callback, 271 272 aggregator=aggregator, 273 + env=build_cogitate_env("ANTHROPIC_API_KEY"), 272 274 ) 273 275 274 276 result = await runner.run()
+52
think/providers/cli.py
··· 16 16 import asyncio 17 17 import json 18 18 import logging 19 + import os 19 20 import shutil 20 21 from pathlib import Path 21 22 from typing import Any, Callable ··· 369 370 return path 370 371 371 372 373 + # --------------------------------------------------------------------------- 374 + # Cogitate Environment 375 + # --------------------------------------------------------------------------- 376 + 377 + 378 + def build_cogitate_env(env_key: str) -> dict[str, str]: 379 + """Build environment dict for a cogitate CLI subprocess. 380 + 381 + By default, strips the provider's API key so the CLI uses its own 382 + platform/account-based auth. Controlled by the ``providers.auth`` 383 + section in journal config: 384 + 385 + "providers": { 386 + "auth": { 387 + "anthropic": "platform" // default — strip key 388 + } 389 + } 390 + 391 + Values: ``"platform"`` (default) strips the key; ``"api_key"`` preserves it. 392 + 393 + Args: 394 + env_key: Environment variable name to consider stripping 395 + (e.g., ``"ANTHROPIC_API_KEY"``). 396 + 397 + Returns: 398 + Copy of ``os.environ`` with the key removed when auth mode is platform. 399 + """ 400 + from think.utils import get_config 401 + 402 + config = get_config() 403 + auth_config = config.get("providers", {}).get("auth", {}) 404 + 405 + # Determine provider name from env_key for config lookup 406 + # e.g., "ANTHROPIC_API_KEY" -> lookup auth_config for matching provider 407 + # We check all auth entries; default is "platform" for any missing provider 408 + auth_mode = "platform" 409 + for provider, mode in auth_config.items(): 410 + from think.providers import PROVIDER_METADATA 411 + 412 + meta = PROVIDER_METADATA.get(provider, {}) 413 + if meta.get("env_key") == env_key: 414 + auth_mode = mode 415 + break 416 + 417 + env = os.environ.copy() 418 + if auth_mode == "platform": 419 + env.pop(env_key, None) 420 + return env 421 + 422 + 372 423 __all__ = [ 373 424 "CLIRunner", 374 425 "ThinkingAggregator", 375 426 "assemble_prompt", 427 + "build_cogitate_env", 376 428 "check_cli_binary", 377 429 ]
+2
think/providers/google.py
··· 46 46 CLIRunner, 47 47 ThinkingAggregator, 48 48 assemble_prompt, 49 + build_cogitate_env, 49 50 ) 50 51 from .shared import ( 51 52 GenerateResult, ··· 621 622 translate=translate, 622 623 callback=callback, 623 624 aggregator=aggregator, 625 + env=build_cogitate_env("GOOGLE_API_KEY"), 624 626 ) 625 627 626 628 result = await runner.run()
+2
think/providers/openai.py
··· 43 43 CLIRunner, 44 44 ThinkingAggregator, 45 45 assemble_prompt, 46 + build_cogitate_env, 46 47 ) 47 48 from think.utils import now_ms 48 49 ··· 203 204 translate=translate, 204 205 callback=cb, 205 206 aggregator=aggregator, 207 + env=build_cogitate_env("OPENAI_API_KEY"), 206 208 ) 207 209 208 210 try: