diff --git a/aibox/README.md b/aibox/README.md index 981a79a..7f5b87a 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -548,7 +548,7 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i |---|---| | `api_url` | 端点 URL(OpenAI Chat Completions 协议) | | `model` | 该档案使用的模型名 | -| `api_key_env` | API Key 环境变量名;**未配置回退 `api.api_key_env`;显式空串 `""` 为本地免密端点**(Ollama/vLLM) | +| `api_key_env` | API Key 环境变量名;**未配置回退 `api.api_key_env`;显式空串 `""` 为本地免密端点**(Ollama/vLLM)。档案端点环境变量取不到时**直接报错,绝不回退 `~/.cert/deepseek.key`**——该文件为 DeepSeek 专属凭据,跨厂商复用属于泄漏(key 文件回退仅对无档案的旧 `api` 扁平节生效) | | `reasoning_enabled` | 档案级思考模式开关(缺省回退 `api.reasoning_enabled`) | | `timeout` | 请求超时秒数 | diff --git a/aibox/llm_client.py b/aibox/llm_client.py index 250a89d..7fe338d 100644 --- a/aibox/llm_client.py +++ b/aibox/llm_client.py @@ -221,13 +221,17 @@ class DeepSeekClient(LLMClient): def resolve_api_key(api_key: Optional[str], config=None, env_name: Optional[str] = None, - allow_missing: bool = False) -> str: + allow_missing: bool = False, + allow_key_file: bool = True) -> str: """解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。 env_name: 档案级环境变量名(来自 models..api_key_env); None 时回退 config 的 api.api_key_env。 allow_missing: 本地免密端点(models..api_key_env 为空串)时, 允许无 Key,返回空串(Bearer 空头,Ollama/vLLM 忽略)。 + allow_key_file: 是否允许回退读取 ~/.cert/deepseek.key。该文件是 DeepSeek + 专属凭据,模型档案端点(其他厂商)必须传 False——环境变量取不到 + 时直接报错,绝不把 A 厂商 Key 发给 B 厂商(跨厂商凭据泄漏)。 """ if api_key: return api_key @@ -244,6 +248,10 @@ def resolve_api_key(api_key: Optional[str], config=None, return env_key if allow_missing: return "" + if not allow_key_file: + raise RuntimeError( + f"模型档案端点缺少 API Key:请设置环境变量 {env_name}," + f"或将该档案的 api_key_env 配置为空串以启用本地免密端点") try: home = os.environ.get("HOME", os.environ["USERPROFILE"]) key_path = os.path.join(home, ".cert", "deepseek.key") @@ -314,7 +322,8 @@ def create_llm_client(config, api_key: Optional[str] = None, timeout = config.get("api.timeout", 30) resolved_key = resolve_api_key(api_key, config, env_name=env_name, - allow_missing=allow_missing) + allow_missing=allow_missing, + allow_key_file=profile is None) if profile is not None: provider = "openai_compatible" diff --git a/aibox/tests/test_models_profile.py b/aibox/tests/test_models_profile.py index 66a8e29..8bc7196 100644 --- a/aibox/tests/test_models_profile.py +++ b/aibox/tests/test_models_profile.py @@ -193,6 +193,62 @@ class TestResolveApiKey: assert resolve_api_key("sk-explicit", _cfg()) == "sk-explicit" +def _key_file_readable(path, *args, **kwargs): + """mock open:deepseek.key 存在且可读(返回假 key),证明泄漏路径是否被触发。""" + if "deepseek.key" in str(path): + import io + m = io.StringIO("sk-deepseek-leaked") + m.name = str(path) + return m + raise OSError(path) + + +class TestH1KeyIsolation: + """H1:跨厂商 API Key 兜底泄漏——档案端点绝不回退读 ~/.cert/deepseek.key。 + + 场景:deepseek.key 文件存在且可读(mock),若修复缺失, + 档案端点会静默拿到该 DeepSeek Key 发往其他厂商。 + """ + + def test_profile_env_missing_never_reads_key_file(self): + """档案显式 env 名 + 环境变量未设置 + key 文件存在 → 报错而非读文件""" + env = {k: "" for k in ("DEEPSEEK_API_KEY", "MOONSHOT_API_KEY")} + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch("builtins.open", side_effect=_key_file_readable): + with pytest.raises(RuntimeError, match="MOONSHOT_API_KEY"): + resolve_api_key(None, _cfg(), env_name="MOONSHOT_API_KEY", + allow_key_file=False) + + def test_profile_via_create_client_never_reads_key_file(self): + """端到端:create_llm_client 档案命中 + env 缺失 + key 文件存在 → 报错""" + env = {k: "" for k in ("DEEPSEEK_API_KEY", "MOONSHOT_API_KEY")} + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch("builtins.open", side_effect=_key_file_readable): + with pytest.raises(RuntimeError, match="MOONSHOT_API_KEY"): + create_llm_client(_cfg(), model="kimi") + + def test_profile_no_env_fallback_missing_raises(self): + """档案未配 api_key_env + 回退变量也未设置 + key 文件存在 → 报错""" + models = dict(MODELS) + models["noenv"] = { + "api_url": "https://ark.example/v1/chat/completions", + "model": "doubao-pro", + } + env = {k: "" for k in ("ARK_API_KEY", "DEEPSEEK_API_KEY")} + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch("builtins.open", side_effect=_key_file_readable): + with pytest.raises(RuntimeError, match="ARK_API_KEY"): + create_llm_client(_cfg(models=models, api_key_env="ARK_API_KEY"), + model="noenv") + + def test_flat_api_still_reads_key_file(self): + """回归保证:无档案旧扁平节 + 环境变量未设置 → 仍回退读 key 文件""" + env = {k: "" for k in ("DEEPSEEK_API_KEY", "MOONSHOT_API_KEY")} + with mock.patch.dict(os.environ, env, clear=False), \ + mock.patch("builtins.open", side_effect=_key_file_readable): + assert resolve_api_key(None, _cfg(models=None)) == "sk-deepseek-leaked" + + # ==================== DeepSeekChat 档案级 reasoning 读取 ==================== class TestChatReasoningFlag: