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
+88 -22

No files matched your search

+19 -2
View File
@@ -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)
+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 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,
+26 -6
View File
@@ -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,
+13
View File
@@ -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)