diff --git a/pyproject.toml b/pyproject.toml index 8ef8a85..47c9b45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,10 +31,10 @@ dependencies = [ "langgraph-sdk>=0.1.48", "langchain-core>=0.3.28", "rich>=13.0.0", - "GitPython>=3.1", + "GitPython>=3.1.50", "rapidfuzz>=3.0.0", "pathspec>=0.11.0", - "aider-chat>=0.69.1", + "aider-chat>=0.82.0", "ripgrepy>=0.1.0", "sympy>=1.12" ] diff --git a/requirements-dev.txt b/requirements-dev.txt index b395d26..85bed4d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -aider-chat==0.69.* -playwright==1.49.* +aider-chat>=0.82.0 +playwright>=1.49.0 pytest-timeout>=2.2.0 pytest>=7.0.0 diff --git a/sparc_cli/proc/interactive.py b/sparc_cli/proc/interactive.py index 365d130..34fb46a 100644 --- a/sparc_cli/proc/interactive.py +++ b/sparc_cli/proc/interactive.py @@ -4,8 +4,8 @@ import os import re +import subprocess import tempfile -import shlex import shutil from typing import List, Tuple @@ -28,48 +28,44 @@ def run_interactive_command(cmd: List[str]) -> Tuple[bytes, int]: # Fail early if cmd is empty if not cmd: raise ValueError("No command provided.") - + # Check that the command exists if shutil.which(cmd[0]) is None: raise FileNotFoundError(f"Command '{cmd[0]}' not found in PATH.") - # Create temp files (we'll always clean them up) + # Create temp file for output (we'll always clean it up) output_file = tempfile.NamedTemporaryFile(prefix="output_", delete=False) - retcode_file = tempfile.NamedTemporaryFile(prefix="retcode_", delete=False) output_path = output_file.name - retcode_path = retcode_file.name output_file.close() - retcode_file.close() - - # Quote arguments for safety - quoted_cmd = ' '.join(shlex.quote(c) for c in cmd) - # Use script to capture output with TTY and save return code - shell_cmd = f"{quoted_cmd}; echo $? > {shlex.quote(retcode_path)}" def cleanup(): - for path in [output_path, retcode_path]: - if os.path.exists(path): - os.remove(path) + if os.path.exists(output_path): + os.remove(output_path) try: # Disable pagers by setting environment variables - os.environ['GIT_PAGER'] = '' - os.environ['PAGER'] = '' - - # Run command with script for TTY and output capture - os.system(f"script -q -c {shlex.quote(shell_cmd)} {shlex.quote(output_path)}") + env = os.environ.copy() + env['GIT_PAGER'] = '' + env['PAGER'] = '' + + # Run command with script for TTY and output capture. + # Pass cmd as a list to script's -c option so no shell interpolation occurs. + # The return code of the inner command is the exit status of script itself. + proc = subprocess.run( + ['script', '-q', '-e', '-c', ' '.join( + subprocess.list2cmdline([c]) if ' ' in c else c for c in cmd + ), output_path], + env=env, + ) + return_code = proc.returncode # Read and clean the output with open(output_path, "rb") as f: output = f.read() - + # Clean ANSI escape sequences and control characters output = re.sub(rb'\x1b\[[0-9;]*[a-zA-Z]', b'', output) # ANSI escape sequences output = re.sub(rb'[\x00-\x08\x0b\x0c\x0e-\x1f]', b'', output) # Control chars - - # Get the return code - with open(retcode_path, "r") as f: - return_code = int(f.read().strip()) except Exception as e: # If something goes wrong, cleanup and re-raise diff --git a/sparc_cli/tools/expert.py b/sparc_cli/tools/expert.py index 2a4a5d6..1792877 100644 --- a/sparc_cli/tools/expert.py +++ b/sparc_cli/tools/expert.py @@ -1,5 +1,6 @@ from typing import List import os +from pathlib import Path from langchain_core.tools import tool from rich.console import Console from rich.panel import Panel @@ -54,27 +55,38 @@ def emit_expert_context(context: str) -> str: return f"Context added." +def _check_safe_path(filepath: str) -> None: + """Raise PermissionError if filepath resolves outside the working directory.""" + safe_root = Path.cwd().resolve() + resolved = Path(filepath).resolve() + if not (str(resolved).startswith(str(safe_root) + os.sep) or resolved == safe_root): + raise PermissionError(f"Access denied: path outside working directory: {filepath}") + + def read_files_with_limit(file_paths: List[str], max_lines: int = 10000) -> str: """Read multiple files and concatenate contents, stopping at line limit. - + Args: file_paths: List of file paths to read max_lines: Maximum total lines to read (default: 10000) - + Note: - Each file's contents will be prefaced with its path as a header - Stops reading files when max_lines limit is reached - Files that would exceed the line limit are truncated + - Files outside the working directory are rejected (path traversal guard) """ total_lines = 0 contents = [] - + for path in file_paths: try: + _check_safe_path(path) + if not os.path.exists(path): console.print(f"Warning: File not found: {path}", style="yellow") continue - + with open(path, 'r', encoding='utf-8') as f: file_content = [] for i, line in enumerate(f): diff --git a/sparc_cli/tools/math/evaluator.py b/sparc_cli/tools/math/evaluator.py index cb24e9c..de0a424 100644 --- a/sparc_cli/tools/math/evaluator.py +++ b/sparc_cli/tools/math/evaluator.py @@ -338,23 +338,16 @@ def _run(self, expression: str) -> str: return f"x = {' or x = '.join(solutions)}" else: - # Handle regular calculations - safe_dict = { - 'abs': abs, - 'float': float, - 'int': int, - 'pow': pow, - 'round': round, - '+': lambda x, y: x + y, - '-': lambda x, y: x - y, - '*': lambda x, y: x * y, - '/': lambda x, y: x / y, - '**': pow - } - result = eval(expression, {"__builtins__": None}, safe_dict) - if isinstance(result, (int, float)): - return str(result if result == int(result) else f"{result:.2f}") - return str(result) + # Handle regular calculations using sympy for safe evaluation. + # eval() with {"__builtins__": None} is insufficient — Python's + # dunder-attribute chain can still escape the sandbox (CWE-78). + # sympy.sympify + evalf performs safe symbolic evaluation instead. + sympy_expr = sympify(expression) + result = sympy_expr.evalf() + result_float = float(result) + if result_float == int(result_float): + return str(int(result_float)) + return f"{result_float:.2f}" except Exception as e: raise ValueError(f"Calculation failed: {str(e)}") diff --git a/sparc_cli/tools/math/validator.py b/sparc_cli/tools/math/validator.py index c330fd4..d20e382 100644 --- a/sparc_cli/tools/math/validator.py +++ b/sparc_cli/tools/math/validator.py @@ -1,7 +1,6 @@ import numpy as np import sympy from typing import Union, Tuple, Any -import ast import logging logger = logging.getLogger(__name__) @@ -109,28 +108,29 @@ def _compare_matrices(mat1: np.ndarray, mat2: np.ndarray) -> Tuple[bool, str]: @staticmethod def _safe_eval(expr: Union[str, float, int]) -> Any: - """Safely evaluate mathematical expressions.""" + """Safely evaluate mathematical expressions using sympy. + + Previously used eval() with an AST whitelist, which is fragile: + - ast.Num is deprecated since Python 3.8 (ast.Constant replaced it), + causing the whitelist to reject valid numeric literals on 3.10+. + - eval() with {"__builtins__": None} or {} can still be escaped via + Python's dunder-attribute chain (CWE-78 / CWE-502). + + sympy.sympify + evalf performs safe, sandboxed symbolic evaluation + without any use of eval() on untrusted input. + """ if isinstance(expr, (float, int, np.ndarray)): return expr - + try: - # Parse the expression to check for safety - tree = ast.parse(str(expr), mode='eval') - - # Only allow basic mathematical operations - allowed_nodes = (ast.Expression, ast.Num, ast.BinOp, ast.UnaryOp, - ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, - ast.USub, ast.UAdd, ast.List, ast.Tuple) - - for node in ast.walk(tree): - if not isinstance(node, allowed_nodes): - raise ValueError(f"Invalid expression component: {type(node).__name__}") - - # Use numpy for evaluation to handle arrays - return eval(compile(tree, '', 'eval'), - {"__builtins__": {}}, - {"np": np, "array": np.array}) - + # sympify parses only mathematical syntax; it rejects arbitrary + # Python expressions and does not execute them. + result = sympy.sympify(str(expr)).evalf() + # Convert scalar results to float; leave compound types as-is. + try: + return float(result) + except (TypeError, ValueError): + return result except Exception as e: logger.error(f"Expression evaluation failed: {str(e)}") raise ValueError(f"Invalid expression: {expr}")