From 9eb13d07b6b8853a282ee58ae12b3dcc8d5f97fa Mon Sep 17 00:00:00 2001 From: Dimar Anez Date: Sat, 18 Jul 2026 22:10:09 -0600 Subject: [PATCH] fix(terminal): tolerate macOS TCC PermissionError in _safe_getcwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../test_safe_getcwd_permission_error.py | 91 +++++++++++++++++++ tools/terminal_tool.py | 13 ++- 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 tests/tools/test_safe_getcwd_permission_error.py diff --git a/tests/tools/test_safe_getcwd_permission_error.py b/tests/tools/test_safe_getcwd_permission_error.py new file mode 100644 index 0000000000..2216cb9148 --- /dev/null +++ b/tests/tools/test_safe_getcwd_permission_error.py @@ -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() diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 3390f472ee..fe1b17039d 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -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("~")