fix: avoid macOS privacy prompts during broad searches
This commit is contained in:
163
tests/tools/test_macos_protected_search.py
Normal file
163
tests/tools/test_macos_protected_search.py
Normal file
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user