diff --git a/src/tool_execution.py b/src/tool_execution.py index a587633c9..8497b157a 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -71,25 +71,35 @@ _SENSITIVE_FILE_PATTERNS: tuple[str, ...] = ( "known_hosts", ) +# Case-folded views used for matching. On a case-insensitive filesystem +# (Windows, default macOS) ".SSH/AUTHORIZED_KEYS" and ".env" resolve to the +# same protected files as their lowercase forms, so the deny-list has to fold +# case before comparing — the sibling resolver already normcases paths for the +# same reason. casefold (not os.path.normcase) because normcase is a no-op on +# POSIX, which is exactly where the macOS read-exfil path lives. +_SENSITIVE_BASENAMES_CF: frozenset[str] = frozenset(b.casefold() for b in _SENSITIVE_BASENAMES) +_SENSITIVE_FILE_PATTERNS_CF: frozenset[str] = frozenset(p.casefold() for p in _SENSITIVE_FILE_PATTERNS) + def _is_sensitive_path(resolved: str) -> bool: """Return True if *resolved* falls under a sensitive directory or matches a sensitive filename — regardless of what root it sits under. + + Matching is case-insensitive: on Windows / default macOS a case-variant + name (``.SSH``, ``AUTHORIZED_KEYS``, ``Id_Rsa``) points at the same file as + the lowercase form, so a case-sensitive check would let it slip past the + deny-list in every file tool that relies on it. """ - parts = resolved.split(os.sep) - filenames: set[str] = {parts[-1]} if parts else set() + parts = [p.casefold() for p in resolved.split(os.sep)] + filename = parts[-1] if parts else "" # Check if any path component is a sensitive directory. for part in parts: - if part in _SENSITIVE_BASENAMES: + if part in _SENSITIVE_BASENAMES_CF: return True # Check filename against known sensitive files. - for pat in _SENSITIVE_FILE_PATTERNS: - if pat in filenames: - return True - - return False + return filename in _SENSITIVE_FILE_PATTERNS_CF def _tool_path_roots() -> list[str]: diff --git a/tests/test_tool_path_confinement.py b/tests/test_tool_path_confinement.py index 6288623c4..f9f1bd03f 100644 --- a/tests/test_tool_path_confinement.py +++ b/tests/test_tool_path_confinement.py @@ -57,6 +57,27 @@ def test_non_sensitive_path(): assert not _is_sensitive_path("/home/user/projects/file.py") +def test_sensitive_case_insensitive(): + """On case-insensitive filesystems (Windows, default macOS) a case-variant + name resolves to the same protected file, so the deny-list must match + regardless of case. Built with os.path.join so the separator is right on + both POSIX and Windows. + """ + from src.tool_execution import _is_sensitive_path + # sensitive directory, varied case + assert _is_sensitive_path(os.path.join("home", "u", ".SSH", "authorized_keys")) + assert _is_sensitive_path(os.path.join("home", "u", ".Gnupg", "pubring.kbx")) + # sensitive filename, varied case + assert _is_sensitive_path(os.path.join("ws", "AUTHORIZED_KEYS")) + assert _is_sensitive_path(os.path.join("ws", "Id_Rsa")) + assert _is_sensitive_path(os.path.join("ws", ".ENV")) + assert _is_sensitive_path(os.path.join("ws", ".Env")) + # both dir and file varied + assert _is_sensitive_path(os.path.join("home", "u", ".SSH", "AUTHORIZED_KEYS")) + # an ordinary file with none of the sensitive names is still allowed + assert not _is_sensitive_path(os.path.join("ws", "Readme.md")) + + # ── Unit tests on _resolve_tool_path ───────────────────────────────── def test_blocks_etc_shadow():