From 5fd6811dfe6fb18c81a510da354bf5356eb2449b Mon Sep 17 00:00:00 2001 From: takealook97 Date: Sat, 1 Aug 2026 10:55:49 +0900 Subject: [PATCH] fix: avoid macOS privacy prompts during broad searches --- tests/tools/test_macos_protected_search.py | 163 +++++++++++++++++++++ tools/file_operations.py | 105 ++++++++++++- tools/file_tools.py | 2 +- 3 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 tests/tools/test_macos_protected_search.py diff --git a/tests/tools/test_macos_protected_search.py b/tests/tools/test_macos_protected_search.py new file mode 100644 index 0000000000..ce6f16910e --- /dev/null +++ b/tests/tools/test_macos_protected_search.py @@ -0,0 +1,163 @@ +"""macOS TCC-safe behavior for broad file searches.""" + +from pathlib import Path + +import tools.file_operations as file_operations +from tools.environments.local import LocalEnvironment +from tools.file_operations import ShellFileOperations, _macos_protected_search_exclusions + + +class RecordingEnvironment: + def __init__(self, cwd): + self.cwd = str(cwd) + self.commands = [] + + def execute(self, command, cwd=None, **kwargs): + self.commands.append(command) + if command.startswith("test -e"): + return {"output": "exists\n", "returncode": 0} + if command.startswith("command -v"): + return {"output": "yes\n", "returncode": 0} + return {"output": "", "returncode": 1} + + +PROTECTED_NAMES = { + "Desktop", + "Documents", + "Downloads", + "Library", + "Movies", + "Music", + "Pictures", +} + + +def test_broad_home_search_excludes_macos_protected_folders(tmp_path): + home = tmp_path / "Users" / "alice" + + exclusions = _macos_protected_search_exclusions( + str(home), cwd=str(tmp_path), home=str(home), platform="darwin" + ) + + assert {Path(item).parts[0] for item in exclusions} == PROTECTED_NAMES + + +def test_explicit_protected_folder_search_is_not_excluded(tmp_path): + home = tmp_path / "Users" / "alice" + + exclusions = _macos_protected_search_exclusions( + str(home / "Downloads"), cwd=str(tmp_path), home=str(home), platform="darwin" + ) + + assert exclusions == [] + + +def test_non_macos_search_has_no_implicit_exclusions(tmp_path): + home = tmp_path / "home" / "alice" + + exclusions = _macos_protected_search_exclusions( + str(home), cwd=str(tmp_path), home=str(home), platform="linux" + ) + + assert exclusions == [] + + +def test_broad_file_search_passes_protected_globs_to_ripgrep(tmp_path, monkeypatch): + home = tmp_path / "Users" / "alice" + home.mkdir(parents=True) + env = RecordingEnvironment(home) + ops = ShellFileOperations(env) + monkeypatch.setattr(file_operations, "_HOME", str(home)) + monkeypatch.setattr(file_operations.sys, "platform", "darwin") + + result = ops.search("*.txt", path=str(home), target="files") + + rg_command = next(command for command in env.commands if command.startswith("rg --files")) + for dirname in PROTECTED_NAMES: + assert f"!{dirname}/**" in rg_command + assert result.warning is not None + assert "macOS protected folders" in result.warning + + +def test_broad_content_search_passes_protected_globs_to_ripgrep(tmp_path, monkeypatch): + home = tmp_path / "Users" / "alice" + home.mkdir(parents=True) + env = RecordingEnvironment(home) + ops = ShellFileOperations(env) + monkeypatch.setattr(file_operations, "_HOME", str(home)) + monkeypatch.setattr(file_operations.sys, "platform", "darwin") + + ops.search("needle", path=str(home), target="content") + + rg_command = next(command for command in env.commands if command.startswith("set -o pipefail; rg")) + for dirname in PROTECTED_NAMES: + assert f"!{dirname}/**" in rg_command + + +def test_legacy_ripgrep_file_fallback_keeps_protected_globs(tmp_path, monkeypatch): + home = tmp_path / "Users" / "alice" + home.mkdir(parents=True) + env = RecordingEnvironment(home) + ops = ShellFileOperations(env) + monkeypatch.setattr(file_operations, "_HOME", str(home)) + monkeypatch.setattr(file_operations.sys, "platform", "darwin") + + ops.search("*.txt", path=str(home), target="files") + + rg_commands = [command for command in env.commands if command.startswith("rg --files")] + assert len(rg_commands) == 2 + for command in rg_commands: + assert "!Downloads/**" in command + + +def test_grep_fallback_excludes_protected_directories(tmp_path, monkeypatch): + home = tmp_path / "Users" / "alice" + home.mkdir(parents=True) + env = RecordingEnvironment(home) + ops = ShellFileOperations(env) + monkeypatch.setattr(file_operations, "_HOME", str(home)) + monkeypatch.setattr(file_operations.sys, "platform", "darwin") + monkeypatch.setattr(ops, "_has_command", lambda command: command == "grep") + + ops.search("needle", path=str(home), target="content") + + grep_command = next(command for command in env.commands if " grep " in command) + for dirname in PROTECTED_NAMES: + assert f"--exclude-dir='{dirname}'" in grep_command + + +def test_find_fallback_prunes_protected_directories(tmp_path, monkeypatch): + home = tmp_path / "Users" / "alice" + home.mkdir(parents=True) + env = RecordingEnvironment(home) + ops = ShellFileOperations(env) + monkeypatch.setattr(file_operations, "_HOME", str(home)) + monkeypatch.setattr(file_operations.sys, "platform", "darwin") + monkeypatch.setattr(ops, "_has_command", lambda command: command == "find") + + ops.search("*.txt", path=str(home), target="files") + + find_commands = [command for command in env.commands if command.startswith("find ")] + assert find_commands + for command in find_commands: + assert str(home / "Downloads") in command + assert "-prune" in command + + +def test_real_ripgrep_does_not_descend_into_protected_folder(tmp_path, monkeypatch): + home = tmp_path / "Users" / "alice" + safe = home / "safe" + protected = home / "Downloads" + safe.mkdir(parents=True) + protected.mkdir() + (safe / "visible.txt").write_text("needle") + (protected / "protected.txt").write_text("needle") + monkeypatch.setattr(file_operations, "_HOME", str(home)) + monkeypatch.setattr(file_operations.sys, "platform", "darwin") + ops = ShellFileOperations(LocalEnvironment(cwd=str(home))) + + result = ops.search("needle", path=str(home), target="content") + + paths = [match.path for match in result.matches] + assert any("visible.txt" in path for path in paths) + assert all("protected.txt" not in path for path in paths) diff --git a/tools/file_operations.py b/tools/file_operations.py index aab5c4ebfa..6b9f96e410 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -29,6 +29,7 @@ import base64 import binascii import os import re +import sys import difflib import hashlib import json @@ -53,6 +54,52 @@ from agent.file_safety import ( _HOME = str(Path.home()) +_MACOS_TCC_PROTECTED_HOME_DIRS = ( + "Desktop", + "Documents", + "Downloads", + "Library", + "Movies", + "Music", + "Pictures", +) + + +def _macos_protected_search_exclusions( + path: str, + *, + cwd: Optional[str] = None, + home: Optional[str] = None, + platform: Optional[str] = None, +) -> List[str]: + """Return protected home directories below a broad macOS search root. + + Direct searches inside a protected directory remain allowed. Only an + ancestor search (for example ``$HOME`` or ``/Users``) receives exclusions, + preventing recursive tools from triggering unattended TCC prompts. + """ + if (platform or sys.platform) != "darwin": + return [] + + home_path = Path(home or Path.home()).expanduser() + root = Path(path).expanduser() + if not root.is_absolute(): + root = Path(cwd or os.getcwd()) / root + root = Path(os.path.normpath(str(root))) + home_path = Path(os.path.normpath(str(home_path))) + + exclusions: List[str] = [] + for dirname in _MACOS_TCC_PROTECTED_HOME_DIRS: + protected = home_path / dirname + try: + relative = protected.relative_to(root) + except ValueError: + continue + if relative.parts: + exclusions.append(relative.as_posix()) + return exclusions + + WRITE_DENIED_PATHS = build_write_denied_paths(_HOME) WRITE_DENIED_PREFIXES = build_write_denied_prefixes(_HOME) @@ -2827,10 +2874,27 @@ class ShellFileOperations(FileOperations): ) if target == "files": - return self._search_files(pattern, path, limit, offset) + result = self._search_files(pattern, path, limit, offset) else: - return self._search_content(pattern, path, file_glob, limit, offset, - output_mode, context) + result = self._search_content(pattern, path, file_glob, limit, offset, + output_mode, context) + + exclusions = self._macos_search_exclusions(path) + if exclusions and not result.error: + skipped = ", ".join(item.split("/")[-1] for item in exclusions) + result.warning = ( + "Skipped macOS protected folders during broad search to avoid " + f"an unattended privacy prompt: {skipped}. Search a protected " + "folder directly when access is intentional." + ) + return result + + def _macos_search_exclusions(self, path: str) -> List[str]: + """Protected descendants to prune for this search root, if any.""" + cwd = getattr(self.env, "cwd", None) or self.cwd + return _macos_protected_search_exclusions( + path, cwd=cwd, home=_HOME, platform=sys.platform + ) def _try_multi_path_search(self, pattern: str, path: str, target: str, file_glob: Optional[str], limit: int, offset: int, @@ -2997,7 +3061,20 @@ class ShellFileOperations(FileOperations): if not has_hidden_path_ancestor: pagination_expr = f" | tail -n +{offset + 1} | head -n {limit}" - cmd = f"find {self._escape_shell_arg(path)}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \ + # Prune protected directories before traversal so macOS never receives + # an access attempt (filtering matched paths after descent is too late). + protected_paths = [ + os.path.normpath(os.path.join(path, item)) + for item in self._macos_search_exclusions(path) + ] + prune_expr = "" + if protected_paths: + prune_terms = " -o ".join( + f"-path {self._escape_shell_arg(item)}" for item in protected_paths + ) + prune_expr = f" \\( {prune_terms} \\) -prune -o" + + cmd = f"find {self._escape_shell_arg(path)}{prune_expr}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \ f"-printf '%T@ %p\\n' 2>/dev/null | sort -rn{pagination_expr}" result = self._exec(cmd, timeout=60) @@ -3005,7 +3082,7 @@ class ShellFileOperations(FileOperations): if not stdout.strip() and not limit_reason: # Try without -printf (BSD find compatibility -- macOS) - cmd_simple = f"find {self._escape_shell_arg(path)}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \ + cmd_simple = f"find {self._escape_shell_arg(path)}{prune_expr}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \ f"2>/dev/null | sort -rn{pagination_expr}" result = self._exec(cmd_simple, timeout=60) stdout, limit_reason = _search_stdout_and_limit(result) @@ -3060,9 +3137,15 @@ class ShellFileOperations(FileOperations): glob_pattern = pattern fetch_limit = limit + offset + exclusion_globs = " ".join( + f"--glob {self._escape_shell_arg(f'!{item}/**')}" + for item in self._macos_search_exclusions(path) + ) + exclusion_args = f" {exclusion_globs}" if exclusion_globs else "" # Try mtime-sorted first (rg 13+); fall back to unsorted if not supported. cmd_sorted = ( - f"rg --files --sortr=modified -g {self._escape_shell_arg(glob_pattern)} " + f"rg --files --sortr=modified -g {self._escape_shell_arg(glob_pattern)}" + f"{exclusion_args} " f"{self._escape_native_tool_arg(path)} 2>/dev/null " f"| head -n {fetch_limit}" ) @@ -3073,7 +3156,8 @@ class ShellFileOperations(FileOperations): if not all_files and not limit_reason: # --sortr may have failed on older rg; retry without it. cmd_plain = ( - f"rg --files -g {self._escape_shell_arg(glob_pattern)} " + f"rg --files -g {self._escape_shell_arg(glob_pattern)}" + f"{exclusion_args} " f"{self._escape_native_tool_arg(path)} 2>/dev/null " f"| head -n {fetch_limit}" ) @@ -3146,6 +3230,10 @@ class ShellFileOperations(FileOperations): if context > 0: cmd_parts.extend(["-C", str(context)]) + # Exclude macOS TCC-protected descendants during broad searches. + for item in self._macos_search_exclusions(path): + cmd_parts.extend(["--glob", self._escape_shell_arg(f"!{item}/**")]) + # Add file glob filter (must be quoted to prevent shell expansion) if file_glob: cmd_parts.extend(["--glob", self._escape_shell_arg(file_glob)]) @@ -3280,6 +3368,9 @@ class ShellFileOperations(FileOperations): # Exclude hidden directories (matching ripgrep's default behavior). # This prevents searching inside .hub/index-cache/, .git/, etc. cmd_parts.append("--exclude-dir='.*'") + for item in self._macos_search_exclusions(path): + dirname = item.split("/")[-1] + cmd_parts.append(f"--exclude-dir={self._escape_shell_arg(dirname)}") # Add context if requested if context > 0: diff --git a/tools/file_tools.py b/tools/file_tools.py index bbed5edfd9..3f3730149f 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -2746,7 +2746,7 @@ PATCH_SCHEMA = { SEARCH_FILES_SCHEMA = { "name": "search_files", - "description": "Search file contents or find files by name. Use this instead of grep/rg/find/ls in terminal. Ripgrep-backed, faster than shell equivalents.\n\nContent search (target='content'): Regex search inside files. Output modes: full matches with line numbers, file paths only, or match counts.\n\nFile search (target='files'): Find files by glob pattern (e.g., '*.py', '*config*'). Also use this instead of ls — results sorted by modification time.", + "description": "Search file contents or find files by name. Use this instead of grep/rg/find/ls in terminal. Ripgrep-backed, faster than shell equivalents. On macOS, broad searches above the user home automatically skip TCC-protected folders (Desktop, Documents, Downloads, Library, Movies, Music, Pictures); target one directly when access is intentional.\n\nContent search (target='content'): Regex search inside files. Output modes: full matches with line numbers, file paths only, or match counts.\n\nFile search (target='files'): Find files by glob pattern (e.g., '*.py', '*config*'). Also use this instead of ls — results sorted by modification time.", "parameters": { "type": "object", "properties": {