diff --git a/src/hai_agents_common/credentials.py b/src/hai_agents_common/credentials.py index 97c8913..2a89bb0 100644 --- a/src/hai_agents_common/credentials.py +++ b/src/hai_agents_common/credentials.py @@ -78,12 +78,12 @@ def absolute_share_url(client: Client | AsyncClient, share_path: str) -> str: def save_api_key(key: str) -> Path: - """Persist the API key to the global `.env` (chmod 600) and the process env.""" - GLOBAL_ENV_PATH.parent.mkdir(parents=True, exist_ok=True) - if not GLOBAL_ENV_PATH.exists(): - GLOBAL_ENV_PATH.write_text("", encoding="utf-8") - with contextlib.suppress(OSError): - GLOBAL_ENV_PATH.chmod(0o600) + """Persist the API key to the global `.env` (file 600, directory 700) and the process env.""" + GLOBAL_ENV_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + GLOBAL_ENV_PATH.parent.chmod(0o700) # mkdir leaves an existing directory's mode untouched, so we set it explicitly + # Create owner-only from the start + os.close(os.open(GLOBAL_ENV_PATH, os.O_WRONLY | os.O_CREAT, 0o600)) + GLOBAL_ENV_PATH.chmod(0o600) # tighten a file that already existed set_key(str(GLOBAL_ENV_PATH), API_KEY_VAR, key) os.environ[API_KEY_VAR] = key return GLOBAL_ENV_PATH diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 8632ae4..dff6c05 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -1,5 +1,8 @@ from __future__ import annotations +import stat +import sys + import httpx import pytest from typer.testing import CliRunner @@ -57,6 +60,27 @@ def test_save_then_clear_roundtrip(monkeypatch): credentials.resolve_api_key() +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions") +def test_saved_key_is_owner_only(tmp_path, monkeypatch): + directory = tmp_path / "hai" + monkeypatch.setattr(credentials, "GLOBAL_ENV_PATH", directory / ".env") + directory.mkdir(mode=0o755) + credentials.GLOBAL_ENV_PATH.write_text("OTHER=1\n") + credentials.GLOBAL_ENV_PATH.chmod(0o644) + + credentials.save_api_key("hk-minted") + + assert stat.S_IMODE(directory.stat().st_mode) == 0o700 + assert stat.S_IMODE(credentials.GLOBAL_ENV_PATH.stat().st_mode) == 0o600 + + credentials.GLOBAL_ENV_PATH.unlink() + directory.rmdir() + credentials.save_api_key("hk-fresh") + + assert stat.S_IMODE(directory.stat().st_mode) == 0o700 + assert stat.S_IMODE(credentials.GLOBAL_ENV_PATH.stat().st_mode) == 0o600 + + def test_absolute_share_url_prepends_base(): class _Wrapper: def get_base_url(self) -> str: diff --git a/tests/test_polling_helpers.py b/tests/test_polling_helpers.py index 97e468c..025ca33 100644 --- a/tests/test_polling_helpers.py +++ b/tests/test_polling_helpers.py @@ -13,7 +13,6 @@ import pytest from hai_agents.polling import ( - MAX_REQUEST_BYTES, assert_request_under_limit, is_settled_session_status, is_terminal_session_status, @@ -80,10 +79,6 @@ class Shot(pydantic.BaseModel): assert_request_under_limit(Shot(png="x" * 200), max_bytes=50) -def test_default_limit_is_five_megabytes() -> None: - assert MAX_REQUEST_BYTES == 5 * 1024 * 1024 - - def test_wait_times_out_on_wall_clock() -> None: client = SimpleNamespace(sessions=_AlwaysRunning()) with pytest.raises(TimeoutError, match="did not settle within 0"):