diff --git a/.github/images/logo-dark.svg b/.github/images/logo-dark.svg new file mode 100644 index 0000000..d6f866a --- /dev/null +++ b/.github/images/logo-dark.svg @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/.github/images/logo-light.svg b/.github/images/logo-light.svg new file mode 100644 index 0000000..5286505 --- /dev/null +++ b/.github/images/logo-light.svg @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/.gitignore b/.gitignore index a01ec14..ea24a53 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ build/ *.egg *.pytest_cache/ +.coverage # Environment .env diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index ebcf89c..d5379ad 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -64,8 +64,6 @@ SUBAGENTS_CONFIG = Path(__file__).parent / "subagent.yaml" # Initialization # ============================================================================= -# Get current date -current_date = datetime.now().strftime("%Y-%m-%d") # Generate system prompt with limits SYSTEM_PROMPT = get_system_prompt( @@ -170,7 +168,7 @@ def _build_base_kwargs(base_backend, base_middleware): subs = load_subagents( SUBAGENTS_CONFIG, tool_registry=tool_registry, - prompt_refs=prompt_refs, + prompt_refs=_build_prompt_refs(), ) _inject_subagent_middleware(subs) return dict( @@ -206,7 +204,7 @@ def load_mcp_and_build_kwargs(base_backend, base_middleware): subs = load_subagents( SUBAGENTS_CONFIG, tool_registry=registry, - prompt_refs=prompt_refs, + prompt_refs=_build_prompt_refs(), ) _inject_subagent_middleware(subs) @@ -228,9 +226,13 @@ def load_mcp_and_build_kwargs(base_backend, base_middleware): ) -prompt_refs = { - "RESEARCHER_INSTRUCTIONS": RESEARCHER_INSTRUCTIONS.format(date=current_date), -} +def _build_prompt_refs() -> dict: + """Build prompt references with the current date (not frozen at import).""" + return { + "RESEARCHER_INSTRUCTIONS": RESEARCHER_INSTRUCTIONS.format( + date=datetime.now().strftime("%Y-%m-%d"), + ), + } base_middleware = [ ToolErrorHandlerMiddleware(), diff --git a/EvoScientist/backends.py b/EvoScientist/backends.py index f9f3dfd..1cbde34 100644 --- a/EvoScientist/backends.py +++ b/EvoScientist/backends.py @@ -2,6 +2,7 @@ import os import re +import shlex import uuid from pathlib import Path @@ -24,7 +25,6 @@ _SYSTEM_PATH_PREFIXES = ( # Dangerous patterns that could escape the workspace BLOCKED_PATTERNS = [ - r'\.\.', # ../ directory traversal r'~/', # home directory r'\bcd\s+/', # cd to absolute path r'\brm\s+-rf\s+/', # rm -rf with absolute path @@ -42,6 +42,38 @@ BLOCKED_COMMANDS = [ ] +def _split_shell_commands(command: str) -> list[str]: + """Split a compound shell command into individual base commands. + + Handles &&, ||, ;, and | operators. Returns base command names. + """ + base_commands: list[str] = [] + # Split by sequential operators first + for segment in re.split(r'\s*(?:&&|\|\||;)\s*', command): + # Then split by pipe + for pipe_seg in segment.split("|"): + pipe_seg = pipe_seg.strip() + if not pipe_seg: + continue + try: + tokens = shlex.split(pipe_seg) + except ValueError: + tokens = pipe_seg.split() + if tokens: + base_commands.append(tokens[0]) + return base_commands + + +def _has_traversal_component(command: str) -> bool: + """Check if command contains '..' as a path component (not substring).""" + from pathlib import PurePosixPath + + for token in command.split(): + if ".." in PurePosixPath(token).parts: + return True + return False + + def validate_command(command: str) -> str | None: """ Validate a shell command for safety. @@ -49,7 +81,15 @@ def validate_command(command: str) -> str | None: Returns: None if command is safe, error message string if blocked. """ - # Check for directory traversal and dangerous patterns + # Check for '..' path traversal as a path component + if _has_traversal_component(command): + return ( + "Command blocked: contains '..' path traversal. " + "All commands must operate within the workspace directory. " + "Use relative paths (e.g., './file.py') instead." + ) + + # Check for dangerous patterns for pattern in BLOCKED_PATTERNS: if re.search(pattern, command): return ( @@ -58,11 +98,11 @@ def validate_command(command: str) -> str | None: f"Use relative paths (e.g., './file.py') instead." ) - # Check for dangerous commands - for cmd in BLOCKED_COMMANDS: - if re.search(rf'\b{cmd}\b', command): + # Check for dangerous commands (pipeline-aware) + for base_cmd in _split_shell_commands(command): + if base_cmd in BLOCKED_COMMANDS: return ( - f"Command blocked: '{cmd}' is not allowed in sandbox mode. " + f"Command blocked: '{base_cmd}' is not allowed in sandbox mode. " f"Only standard development commands are permitted." ) @@ -328,7 +368,7 @@ class CustomSandboxBackend(LocalShellBackend): return super()._resolve_path(key) - def execute(self, command: str) -> ExecuteResponse: + def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: """ Execute shell command in sandbox environment. @@ -362,4 +402,4 @@ class CustomSandboxBackend(LocalShellBackend): ) # Delegate to parent for subprocess execution - return super().execute(command) + return super().execute(command, timeout=timeout) diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index 9a2ddcd..7b1220b 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -10,6 +10,8 @@ from typing import Any, Optional import typer # type: ignore[import-untyped] from rich.table import Table +from rich.markup import escape + from ..stream.display import console from ..paths import ensure_dirs, set_workspace_root from ._app import app, config_app, mcp_app, channel_app @@ -213,9 +215,9 @@ def config_set( from ..config import set_config_value if set_config_value(key, value): - console.print(f"[green]Set {key}[/green]") + console.print(f"[green]Set {escape(key)}[/green]") else: - console.print(f"[red]Invalid key: {key}[/red]") + console.print(f"[red]Invalid key: {escape(key)}[/red]") raise typer.Exit(1) @@ -572,7 +574,7 @@ def _configure_logging(): if record.levelno == logging.WARNING: # Use Rich console to print dim warning msg = record.getMessage() - console.print(f"[dim yellow]\u26a0\ufe0f Warning:[/dim yellow] [dim]{msg}[/dim]") + console.print(f"[dim yellow]\u26a0\ufe0f Warning:[/dim yellow] [dim]{escape(msg)}[/dim]") else: super().emit(record) diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index d43d9af..603ffa4 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -16,6 +16,7 @@ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory # type: ignore[i from prompt_toolkit.formatted_text import HTML # type: ignore[import-untyped] from prompt_toolkit.shortcuts import CompleteStyle # type: ignore[import-untyped] from prompt_toolkit.styles import Style as PtStyle # type: ignore[import-untyped] +from rich.markup import escape from rich.table import Table from rich.text import Text @@ -236,11 +237,11 @@ def cmd_interactive( if len(similar) == 1: return similar[0] if len(similar) > 1: - console.print(f"[yellow]Ambiguous thread ID '{tid}'. Matches:[/yellow]") + console.print(f"[yellow]Ambiguous thread ID '{escape(tid)}'. Matches:[/yellow]") for s in similar: console.print(f" [cyan]{s}[/cyan]") return None - console.print(f"[red]Thread '{tid}' not found.[/red]") + console.print(f"[red]Thread '{escape(tid)}' not found.[/red]") return None async def _cmd_threads(): @@ -701,7 +702,7 @@ def cmd_interactive( state["running"] = False break else: - console.print(f"[red]Error: {e}[/red]") + console.print(f"[red]Error: {escape(str(e))}[/red]") finally: queue_task.cancel() try: diff --git a/EvoScientist/stream/events.py b/EvoScientist/stream/events.py index cc96936..0ed9ea4 100644 --- a/EvoScientist/stream/events.py +++ b/EvoScientist/stream/events.py @@ -4,6 +4,7 @@ Async generator that streams events from an agent graph, plus helpers for processing AI message chunks and tool results. """ +import asyncio import base64 import mimetypes import os @@ -260,15 +261,19 @@ async def stream_agent_events( content_blocks: list[dict[str, Any]] = [] if message: content_blocks.append({"type": "text", "text": message}) + def _read_file_b64(path: str) -> str: + with open(path, "rb") as fh: + return base64.b64encode(fh.read()).decode("ascii") + file_refs: list[str] = [] for path in media: ext = os.path.splitext(path)[1].lower() - if ext in _IMAGE_EXTS and os.path.isfile(path): - fsize = os.path.getsize(path) + is_image = ext in _IMAGE_EXTS and await asyncio.to_thread(os.path.isfile, path) + if is_image: + fsize = await asyncio.to_thread(os.path.getsize, path) if fsize <= _MAX_INLINE_SIZE: mime = mimetypes.guess_type(path)[0] or "image/png" - with open(path, "rb") as fh: - b64 = base64.b64encode(fh.read()).decode("ascii") + b64 = await asyncio.to_thread(_read_file_b64, path) content_blocks.append({"type": "image_url", "image_url": { "url": f"data:{mime};base64,{b64}", }}) diff --git a/EvoScientist/stream/formatter.py b/EvoScientist/stream/formatter.py index a42529c..022160b 100644 --- a/EvoScientist/stream/formatter.py +++ b/EvoScientist/stream/formatter.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from enum import Enum from typing import Any, List +from rich.markup import escape from rich.panel import Panel from rich.syntax import Syntax from rich.text import Text @@ -123,7 +124,7 @@ class ToolResultFormatter: display = truncate(content, max_length) return [Panel( Text(display, style="green"), - title=f"{name} OK", + title=f"{escape(name)} OK", border_style="green", )] @@ -131,7 +132,7 @@ class ToolResultFormatter: display = truncate(content, max_length) return [Panel( Text(display, style="red"), - title=f"{name} FAILED", + title=f"{escape(name)} FAILED", border_style="red", )] @@ -155,7 +156,7 @@ class ToolResultFormatter: display = truncate(content, max_length) return [Panel( Markdown(display), - title=f"{name}", + title=escape(name), border_style="cyan dim", )] diff --git a/README.md b/README.md index a4c02da..48dd512 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ -