fix(terminal): tolerate macOS TCC PermissionError in _safe_getcwd

On macOS with TCC (Transparency, Consent, and Control), os.getcwd()
raises PermissionError: [Errno 1] Operation not permitted — not
FileNotFoundError — when the process CWD is under a protected location
(~/Documents, ~/Desktop, ~/Downloads) and the calling process lacks
Full Disk Access.

_safe_getcwd() only caught FileNotFoundError (deleted CWD), so the
terminal-tool cleanup thread, which calls _get_env_config() →
_safe_getcwd() every 60 s, logged a full stack trace on every tick.
This accumulated hundreds of MB of noise in mcp-stderr.log (observed
184 MB on a single-day session) without breaking functionality — the
cleanup thread's outer try/except swallowed the exception, but
exc_info=True kept emitting the traceback.

Fix: add PermissionError to the existing except clause so the fallback
chain (TERMINAL_CWD → $HOME) runs, matching the existing pattern for
deleted-CWD recovery (#17558). Complements #66306, which handles
PermissionError from subprocess.Popen(cwd=...) for an inaccessible
configured cwd on Linux; this handles the distinct case where the
live process CWD itself is TCC-blocked.

Tests cover: PermissionError fallback to $HOME, TERMINAL_CWD priority,
FileNotFoundError regression, happy path unchanged, and unrelated
OSError (NotADirectoryError) still propagating instead of being
swallowed.
This commit is contained in:
Dimar Anez
2026-07-18 22:10:09 -06:00
committed by Teknium
parent bd134d0f30
commit 9eb13d07b6
2 changed files with 100 additions and 4 deletions

View File

@@ -0,0 +1,91 @@
"""Regression tests for _safe_getcwd() PermissionError handling (macOS TCC).
Background: on macOS, when the process CWD is under a TCC-protected location
(``~/Documents``, ``~/Desktop``, ``~/Downloads``) and the calling process
lacks Full Disk Access, ``os.getcwd()`` raises ``PermissionError: [Errno 1]
Operation not permitted`` — not ``FileNotFoundError``.
Before the fix, ``_safe_getcwd`` only caught ``FileNotFoundError``, so the
terminal-tool cleanup thread (which calls ``_get_env_config()`` →
``_safe_getcwd()`` every 60 s) logged a full stack trace on every tick,
accumulating hundreds of MB of noise in ``mcp-stderr.log`` without breaking
functionality. After the fix, ``PermissionError`` falls back to
``TERMINAL_CWD`` or ``$HOME`` just like a deleted CWD already does.
"""
import os
import pytest
import tools.terminal_tool as terminal_tool
class _GetcwdPatcher:
"""Context manager that temporarily replaces ``os.getcwd`` with *fn*."""
def __init__(self, fn):
self.fn = fn
self._original = None
def __enter__(self):
self._original = os.getcwd
os.getcwd = self.fn
return self
def __exit__(self, *exc):
os.getcwd = self._original
def _raise(exc):
def _fn():
raise exc
return _fn
def test_permission_error_falls_back_to_home(monkeypatch):
"""macOS TCC EPERM on os.getcwd() must fall back to $HOME, not propagate."""
monkeypatch.delenv("TERMINAL_CWD", raising=False)
with _GetcwdPatcher(_raise(PermissionError(1, "Operation not permitted"))):
result = terminal_tool._safe_getcwd()
assert result == os.path.expanduser("~")
def test_permission_error_prefers_terminal_cwd(monkeypatch):
"""TERMINAL_CWD takes priority over $HOME when the live CWD is TCC-blocked."""
monkeypatch.setenv("TERMINAL_CWD", "/custom/from/env")
with _GetcwdPatcher(_raise(PermissionError(1, "Operation not permitted"))):
result = terminal_tool._safe_getcwd()
assert result == "/custom/from/env"
def test_file_not_found_still_handled(monkeypatch):
"""Regression guard: the existing FileNotFoundError path must keep working."""
monkeypatch.delenv("TERMINAL_CWD", raising=False)
with _GetcwdPatcher(_raise(FileNotFoundError(2, "No such file or directory"))):
result = terminal_tool._safe_getcwd()
assert result == os.path.expanduser("~")
def test_happy_path_unchanged():
"""Normal os.getcwd() must pass through untouched."""
# Don't patch getcwd — use the real one
result = terminal_tool._safe_getcwd()
assert result == os.getcwd()
def test_os_error_not_swallowed(monkeypatch):
"""Unrelated OSError subclasses must still propagate (don't over-catch)."""
monkeypatch.delenv("TERMINAL_CWD", raising=False)
# NotADirectoryError is an OSError but neither FileNotFoundError nor
# PermissionError — it should escape so callers see the real problem.
with pytest.raises(OSError):
with _GetcwdPatcher(_raise(NotADirectoryError(20, "Not a directory"))):
terminal_tool._safe_getcwd()

View File

@@ -1618,16 +1618,21 @@ def _parse_env_var(name: str, default: str, converter: Any = int, type_label: st
def _safe_getcwd() -> str:
"""Return the current working directory, tolerating a deleted CWD.
"""Return the current working directory, tolerating a deleted or
permission-restricted CWD.
``os.getcwd()`` raises FileNotFoundError when the process's working
directory has been removed out from under it (e.g. a scratch workspace
that was cleaned up mid-session). Fall back to TERMINAL_CWD, then the
user's home directory, so terminal setup never crashes on a stale CWD.
that was cleaned up mid-session). On macOS with TCC (Transparency,
Consent, and Control), it raises PermissionError (EPERM) when the CWD
is under a protected location (~/Documents, ~/Desktop, ~/Downloads)
and the calling process lacks Full Disk Access. Fall back to
TERMINAL_CWD, then the user's home directory, so terminal setup never
crashes on a stale or TCC-blocked CWD.
"""
try:
return os.getcwd()
except FileNotFoundError:
except (FileNotFoundError, PermissionError):
return os.getenv("TERMINAL_CWD") or os.path.expanduser("~")