fix: avoid macOS privacy prompts during broad searches

This commit is contained in:
takealook97
2026-08-01 10:55:49 +09:00
committed by Teknium
parent cddb908aab
commit 5fd6811dfe
3 changed files with 262 additions and 8 deletions

View 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)

View File

@@ -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:

View File

@@ -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": {