mirror of
https://github.com/instructkr/claw-code.git
synced 2026-11-07 09:37:54 +01:00
fix(security): deny unresolvable paths and enable Git bash detection on Windows
This commit is contained in:
1 parent
3463331171
commit
0d8b50b378
4 files changed
+88
-22
No files matched your search
+19
-2
@@ -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:
|
||||||
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):
|
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user