diff --git a/src/path_scope.py b/src/path_scope.py index 2a8a4af2..be259760 100644 --- a/src/path_scope.py +++ b/src/path_scope.py @@ -72,7 +72,15 @@ class WorkspacePathScope: path = base / path expanded = self._expand_glob(path) for expanded_path in expanded: - resolved = expanded_path.resolve(strict=False) + try: + resolved = expanded_path.resolve(strict=False) + except (OSError, ValueError, RuntimeError): + return PathScopeDecision( + False, + 'path cannot be resolved or is invalid', + str(candidate), + str(expanded_path), + ) if not any(_is_relative_to(resolved, root) for root in self.roots): return PathScopeDecision( False, @@ -80,7 +88,16 @@ class WorkspacePathScope: str(candidate), str(resolved), ) - return PathScopeDecision(True, 'path is inside workspace scope', str(candidate), str(expanded[0].resolve(strict=False))) + try: + final_resolved = str(expanded[0].resolve(strict=False)) + except (OSError, ValueError, RuntimeError): + return PathScopeDecision( + False, + 'path cannot be resolved or is invalid', + str(candidate), + str(expanded[0]), + ) + return PathScopeDecision(True, 'path is inside workspace scope', str(candidate), final_resolved) def _expand_glob(self, path: Path) -> tuple[Path, ...]: path_text = str(path) diff --git a/tests/test_pre_push_hook_contract.py b/tests/test_pre_push_hook_contract.py index 7a958a05..0cc1f287 100644 --- a/tests/test_pre_push_hook_contract.py +++ b/tests/test_pre_push_hook_contract.py @@ -1,22 +1,37 @@ -from __future__ import annotations - -import unittest -import os - -def require_bash() -> bool: - import shutil - bash = shutil.which('bash') - if os.name == 'nt': - return False - return bash is not None - - import os +import shutil import subprocess import unittest from pathlib import Path +def get_bash_executable() -> str | None: + if os.name == 'nt': + for candidate in ( + r'C:\Program Files\Git\bin\bash.exe', + r'C:\Program Files\Git\usr\bin\bash.exe', + r'C:\Program Files (x86)\Git\bin\bash.exe', + os.path.expandvars(r'%LOCALAPPDATA%\Programs\Git\bin\bash.exe'), + ): + if os.path.exists(candidate): + return candidate + bash = shutil.which('bash') + if bash and 'WindowsApps' not in bash: + return bash + return None + + +def require_bash() -> bool: + bash = get_bash_executable() + if not bash: + return False + try: + res = subprocess.run([bash, '-c', 'echo 1'], capture_output=True, text=True, timeout=2) + return res.returncode == 0 + except Exception: + return False + + REPO_ROOT = Path(__file__).resolve().parents[1] PRE_PUSH_HOOK = REPO_ROOT / '.github' / 'hooks' / 'pre-push' @@ -24,11 +39,12 @@ PRE_PUSH_HOOK = REPO_ROOT / '.github' / 'hooks' / 'pre-push' class PrePushHookContractTests(unittest.TestCase): @unittest.skipUnless(require_bash(), 'Requires bash') def test_skip_escape_hatch_exits_successfully_with_stderr_notice(self) -> None: + bash_cmd = get_bash_executable() or 'bash' env = os.environ.copy() env['SKIP_CLAW_PRE_PUSH_BUILD'] = '1' result = subprocess.run( - ['bash', str(PRE_PUSH_HOOK)], + [bash_cmd, str(PRE_PUSH_HOOK)], cwd=REPO_ROOT, env=env, check=True, diff --git a/tests/test_roadmap_helpers.py b/tests/test_roadmap_helpers.py index 27031c5d..3b376357 100644 --- a/tests/test_roadmap_helpers.py +++ b/tests/test_roadmap_helpers.py @@ -16,18 +16,38 @@ DOGFOOD_PROBE = REPO_ROOT / 'scripts' / 'dogfood-probe.py' import sys -def require_bash() -> bool: +def get_bash_executable() -> str | None: import os - import shutil - bash = shutil.which('bash') if os.name == 'nt': - # On Windows, 'bash' often resolves to WSL which fails if not configured + for candidate in ( + r'C:\Program Files\Git\bin\bash.exe', + r'C:\Program Files\Git\usr\bin\bash.exe', + r'C:\Program Files (x86)\Git\bin\bash.exe', + os.path.expandvars(r'%LOCALAPPDATA%\Programs\Git\bin\bash.exe'), + ): + if os.path.exists(candidate): + return candidate + bash = shutil.which('bash') + if bash and 'WindowsApps' not in bash: + return bash + return None + + +def require_bash() -> bool: + bash = get_bash_executable() + if not bash: return False - return bash is not None + try: + res = subprocess.run([bash, '-c', 'echo 1'], capture_output=True, text=True, timeout=2) + return res.returncode == 0 + except Exception: + return False + def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]: + bash_cmd = get_bash_executable() or 'bash' return subprocess.run( - ['bash', str(script), str(roadmap)], + [bash_cmd, str(script), str(roadmap)], cwd=REPO_ROOT, capture_output=True, text=True, diff --git a/tests/test_security_scope.py b/tests/test_security_scope.py index ac56a384..e054b666 100644 --- a/tests/test_security_scope.py +++ b/tests/test_security_scope.py @@ -166,6 +166,19 @@ class WorkspacePathScopeTests(unittest.TestCase): self.assertFalse(decision.allowed) self.assertIn('outside workspace scope', decision.reason) + def test_unresolvable_path_raises_oserror_is_denied(self) -> None: + """Verify that paths raising OSError during resolve() are explicitly denied.""" + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) / 'workspace' + workspace.mkdir() + scope = WorkspacePathScope.from_root(workspace) + + from unittest.mock import patch + with patch.object(Path, 'resolve', side_effect=OSError('dangling symlink or filesystem error')): + decision = scope.validate_path(str(workspace / 'broken_link.txt')) + self.assertFalse(decision.allowed) + self.assertIn('cannot be resolved', decision.reason) + def test_glob_expansion_must_stay_inside_workspace(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp)