diff --git a/cli/utils.py b/cli/utils.py index da8524d2d..987ffa496 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -643,7 +643,11 @@ def ensure_api_key(provider: str) -> str | None: return None env_path = find_dotenv(usecwd=True) or str(Path.cwd() / ".env") - Path(env_path).touch(exist_ok=True) + # The file holds credentials, so make it owner-only before writing: create + # it 0600 when absent, and tighten an existing one (set_key keeps the mode). + if not os.path.exists(env_path): + os.close(os.open(env_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)) + os.chmod(env_path, 0o600) set_key(env_path, env_var, key) os.environ[env_var] = key console.print(f"[green]Saved {env_var} to {env_path}[/green]") diff --git a/tests/test_api_key_env.py b/tests/test_api_key_env.py index 7361ea7b5..8bd0154a6 100644 --- a/tests/test_api_key_env.py +++ b/tests/test_api_key_env.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import stat from unittest.mock import patch import pytest @@ -146,3 +147,47 @@ def test_ensure_api_key_updates_existing_env_file(monkeypatch, tmp_path, cli_uti assert "OPENAI_API_KEY" in content and "sk-existing" in content assert "OTHER=value" in content assert "OPENROUTER_API_KEY" in content and "sk-openrouter-new" in content + + +def _prompt_key(cli_utils, monkeypatch, tmp_path, key="sk-typed-in"): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setattr(cli_utils, "find_dotenv", lambda **k: "") + with patch.object(cli_utils, "questionary") as mock_q: + mock_q.password.return_value.ask.return_value = key + cli_utils.ensure_api_key("openai") + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes") +def test_saved_key_file_is_owner_only(monkeypatch, cli_utils, tmp_path): + # The prompt writes a real credential; the file must not be readable by + # other local users whatever the umask is. + old = os.umask(0o002) + try: + _prompt_key(cli_utils, monkeypatch, tmp_path) + finally: + os.umask(old) + env = tmp_path / ".env" + assert "sk-typed-in" in env.read_text() + assert stat.S_IMODE(env.stat().st_mode) == 0o600 + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes") +def test_existing_key_file_is_tightened_before_writing(monkeypatch, cli_utils, tmp_path): + env = tmp_path / ".env" + env.write_text("OTHER=1\n") + os.chmod(env, 0o664) + _prompt_key(cli_utils, monkeypatch, tmp_path) + assert stat.S_IMODE(env.stat().st_mode) == 0o600 + assert "OTHER=1" in env.read_text() + + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes") +def test_read_only_key_file_is_still_updated(monkeypatch, cli_utils, tmp_path): + env = tmp_path / ".env" + env.write_text("OTHER=1\n") + os.chmod(env, 0o400) + _prompt_key(cli_utils, monkeypatch, tmp_path) + assert "sk-typed-in" in env.read_text() + assert stat.S_IMODE(env.stat().st_mode) == 0o600