Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand Down
4 changes: 2 additions & 2 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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
44 changes: 20 additions & 24 deletions sparc_cli/proc/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

import os
import re
import subprocess
import tempfile
import shlex
import shutil
from typing import List, Tuple

Expand All @@ -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
Expand Down
20 changes: 16 additions & 4 deletions sparc_cli/tools/expert.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down
27 changes: 10 additions & 17 deletions sparc_cli/tools/math/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}")

Expand Down
40 changes: 20 additions & 20 deletions sparc_cli/tools/math/validator.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import numpy as np
import sympy
from typing import Union, Tuple, Any
import ast
import logging

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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, '<string>', '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}")
Loading