diff --git a/src/anthropic/lib/credentials/_providers.py b/src/anthropic/lib/credentials/_providers.py index df1c68af2..2a3724eec 100644 --- a/src/anthropic/lib/credentials/_providers.py +++ b/src/anthropic/lib/credentials/_providers.py @@ -472,7 +472,9 @@ def _atomic_write_credentials(self, data: Dict[str, Any]) -> None: fd, tmp = tempfile.mkstemp(dir=parent, prefix=f".{self._credentials_path.name}.", suffix=".tmp") try: try: - os.fchmod(fd, 0o600) + fchmod = getattr(os, "fchmod", None) + if fchmod is not None: + fchmod(fd, 0o600) os.write(fd, _json_dumps_secrets(data, indent=2)) os.fsync(fd) finally: diff --git a/tests/lib/test_credentials.py b/tests/lib/test_credentials.py index c3905a833..e75f95b57 100644 --- a/tests/lib/test_credentials.py +++ b/tests/lib/test_credentials.py @@ -605,6 +605,44 @@ def test_workload_identity_token_omitted_uses_env( # -- "type": "authorized_user" ---------------------------------------- + def test_atomic_write_credentials_without_fchmod( + self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + credentials_path = tmp_path / "credentials" / "default.json" + provider = CredentialsFile() + provider._credentials_path = credentials_path # pyright: ignore[reportPrivateUsage] + monkeypatch.delattr(os, "fchmod", raising=False) + + provider._atomic_write_credentials( # pyright: ignore[reportPrivateUsage] + {"access_token": "new-token", "refresh_token": "new-refresh"} + ) + + assert json.loads(credentials_path.read_text()) == { + "access_token": "new-token", + "refresh_token": "new-refresh", + } + assert not list(credentials_path.parent.glob(".default.json.*.tmp")) + + def test_atomic_write_credentials_uses_restrictive_mode_when_fchmod_available( + self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + credentials_path = tmp_path / "credentials" / "default.json" + provider = CredentialsFile() + provider._credentials_path = credentials_path # pyright: ignore[reportPrivateUsage] + calls: List[tuple[int, int]] = [] + + def record_fchmod(fd: int, mode: int) -> None: + calls.append((fd, mode)) + + monkeypatch.setattr(os, "fchmod", record_fchmod, raising=False) + + provider._atomic_write_credentials( # pyright: ignore[reportPrivateUsage] + {"access_token": "new-token"} + ) + + assert len(calls) == 1 + assert calls[0][1] == 0o600 + @pytest.mark.respx(base_url=BASE_URL) def test_authorized_user_refresh_and_writeback(self, respx_mock: MockRouter, tmp_path: pathlib.Path) -> None: """Refresh writes back to credentials/ only — configs/ stays untouched."""