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
+86 -20

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)
+29 -13
View File
@@ -1,22 +1,37 @@
from __future__ import annotations
import unittest
import os import os
def require_bash() -> bool:
import shutil import shutil
bash = shutil.which('bash')
if os.name == 'nt':
return False
return bash is not None
import os
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)