fix(security): deny unresolvable paths and enable Git bash detection on Windows

This commit is contained in:
Gracy769 authored and gaebal-gajae committed 2026-10-12 22:06:08 +00:00
1 parent 3463331171
commit 0d8b50b378
4 files changed
+87 -21

No files matched your search

+18 -1
View File
@@ -72,7 +72,15 @@ class WorkspacePathScope:
path = base / path path = base / path
expanded = self._expand_glob(path) expanded = self._expand_glob(path)
for expanded_path in expanded: for expanded_path in expanded:
try:
resolved = expanded_path.resolve(strict=False) 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): if not any(_is_relative_to(resolved, root) for root in self.roots):
return PathScopeDecision( return PathScopeDecision(
False, False,
@@ -80,7 +88,16 @@ class WorkspacePathScope:
str(candidate), str(candidate),
str(resolved), 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, ...]: def _expand_glob(self, path: Path) -> tuple[Path, ...]:
path_text = str(path) path_text = str(path)
+30 -14
View File
@@ -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 os
import shutil
import subprocess import subprocess
import unittest import unittest
from pathlib import Path 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] REPO_ROOT = Path(__file__).resolve().parents[1]
PRE_PUSH_HOOK = REPO_ROOT / '.github' / 'hooks' / 'pre-push' 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): class PrePushHookContractTests(unittest.TestCase):
@unittest.skipUnless(require_bash(), 'Requires bash') @unittest.skipUnless(require_bash(), 'Requires bash')
def test_skip_escape_hatch_exits_successfully_with_stderr_notice(self) -> None: def test_skip_escape_hatch_exits_successfully_with_stderr_notice(self) -> None:
bash_cmd = get_bash_executable() or 'bash'
env = os.environ.copy() env = os.environ.copy()
env['SKIP_CLAW_PRE_PUSH_BUILD'] = '1' env['SKIP_CLAW_PRE_PUSH_BUILD'] = '1'
result = subprocess.run( result = subprocess.run(
['bash', str(PRE_PUSH_HOOK)], [bash_cmd, str(PRE_PUSH_HOOK)],
cwd=REPO_ROOT, cwd=REPO_ROOT,
env=env, env=env,
check=True, check=True,
+26 -6
View File
@@ -16,18 +16,38 @@ DOGFOOD_PROBE = REPO_ROOT / 'scripts' / 'dogfood-probe.py'
import sys import sys
def require_bash() -> bool: def get_bash_executable() -> str | None:
import os import os
import shutil
bash = shutil.which('bash')
if os.name == 'nt': 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 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]: def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]:
bash_cmd = get_bash_executable() or 'bash'
return subprocess.run( return subprocess.run(
['bash', str(script), str(roadmap)], [bash_cmd, str(script), str(roadmap)],
cwd=REPO_ROOT, cwd=REPO_ROOT,
capture_output=True, capture_output=True,
text=True, text=True,
+13
View File
@@ -166,6 +166,19 @@ class WorkspacePathScopeTests(unittest.TestCase):
self.assertFalse(decision.allowed) self.assertFalse(decision.allowed)
self.assertIn('outside workspace scope', decision.reason) 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: def test_glob_expansion_must_stay_inside_workspace(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)