Skip to content
Open
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
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2024-05-18 - JSON Parse Performance Optimization
**Learning:** `json_parse_dirty` in `helpers/extract_tools.py` was directly relying on `DirtyJson.parse_string` which is significantly slower than the standard library's `json.loads` for well-formed JSON strings. This was a bottleneck as this method is used frequently to parse extracted JSON strings.
**Action:** When working with custom fallback parsers for data formats like JSON, always attempt parsing with the standard library's fast path first (e.g. `json.loads()`), falling back to the custom parser only upon a `JSONDecodeError`.
12 changes: 12 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## 2024-05-24 - simpleeval Sandbox Escape (CVE-2026-32640)
**Vulnerability:** The `simpleeval` package used in the project allowed arbitrary function execution resulting in potential sandbox escapes or remote code injection during the evaluation of user inputs and template conditions.
**Learning:** Using `simple_eval()` without explicitly disabling `functions` allowed unintended function executions.
**Prevention:** Update `simpleeval` dependency to version >=1.0.5 and always use `SimpleEval(names=..., functions={}).eval(...)` to prevent code execution when only evaluating variables.
## 2024-05-24 - Path Traversal in API Files Get
**Vulnerability:** The `ApiFilesGet` endpoint allowed arbitrary reading of files by not verifying if the dynamically generated `external_path` was inside the application base directory or other allowed directories. Attackers could supply paths like `/a0/../../../../etc/passwd` to exfiltrate system data since they were simply translated to absolute paths and opened directly.
**Learning:** `helpers.files.get_abs_path` resolves paths but does not inherently enforce boundary checks or prevent directory traversal `../` beyond simply normalizing the string. If `get_abs_path` is passed a path like `../../`, it will happily resolve it outside of `_base_dir`.
**Prevention:** Always follow calls to `get_abs_path` on user-provided path inputs with a definitive security boundary check using `helpers.files.is_in_base_dir(path)` before accessing the filesystem for reading or writing.
## 2025-03-05 - Add input validation for plugin path resolution
**Vulnerability:** Path Traversal via `plugin_name`, `project_name` and `agent_profile` parameters
**Learning:** `helpers.files.get_abs_path` does not prevent path traversal if user inputs (such as parameters from API requests) contain characters like `/`, `\`, or `..`. A blocklist strategy (rejecting specific sequences like `..`) is fragile to evasion (such as url-encoding tricks). An allowlist validating against acceptable filename characters securely constraints input parameters, effectively defending against attackers resolving arbitrary paths and exploiting mechanisms like `_run_execute_script`.
**Prevention:** Strictly validate parameters that dictate directory traversal before passing them to path construction functions. E.g., apply a Regex allowlist like `re.fullmatch(r'^[a-zA-Z0-9_-]+$', name)` at the API boundary, with a second defense-in-depth layer within core resolution functions.
5 changes: 5 additions & 0 deletions api/api_files_get.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ async def process(self, input: dict, request: Request) -> dict | Response:
external_path = path
filename = os.path.basename(path)

# Security check: prevent path traversal
if not files.is_in_base_dir(external_path):
PrintStyle.warning(f"Security: Path traversal attempt blocked for path: {path}")
continue

# Check if file exists
if not os.path.exists(external_path):
PrintStyle.warning(f"File not found: {path}")
Expand Down
20 changes: 20 additions & 0 deletions api/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,23 @@ async def process(self, input: dict, request: Request) -> dict | Response:

return Response(status=400, response=f"Unknown action: {action}")

def _is_valid(self, plugin_name: str, project_name: str = "", agent_profile: str = "", allow_star: bool = False) -> Response | None:
if not plugins.validate_plugin_name(plugin_name):
return Response(status=400, response="Invalid plugin_name")
if project_name and not plugins.validate_asset_name(project_name, allow_star=allow_star):
return Response(status=400, response="Invalid project_name")
if agent_profile and not plugins.validate_asset_name(agent_profile, allow_star=allow_star):
return Response(status=400, response="Invalid agent_profile")
return None

@extension.extensible
def _get_config(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
project_name = input.get("project_name", "")
agent_profile = input.get("agent_profile", "")
if not plugin_name:
return Response(status=400, response="Missing plugin_name")
if err := self._is_valid(plugin_name, project_name, agent_profile): return err

result = plugins.find_plugin_assets(
plugins.CONFIG_FILE_NAME,
Expand Down Expand Up @@ -97,6 +107,7 @@ def _get_toggle_status(self, input: dict) -> dict | Response:
agent_profile = input.get("agent_profile", "")
if not plugin_name:
return Response(status=400, response="Missing plugin_name")
if err := self._is_valid(plugin_name, project_name, agent_profile): return err

meta = plugins.get_plugin_meta(plugin_name)
if not meta:
Expand Down Expand Up @@ -147,6 +158,7 @@ def _list_configs(self, input: dict) -> dict | Response:
asset_type = input.get("asset_type", "config")
if not plugin_name:
return Response(status=400, response="Missing plugin_name")
if err := self._is_valid(plugin_name): return err

configs = plugins.find_plugin_assets(
(
Expand All @@ -168,6 +180,7 @@ def _delete_config(self, input: dict) -> dict | Response:
path = input.get("path", "")
if not plugin_name:
return Response(status=400, response="Missing plugin_name")
if err := self._is_valid(plugin_name): return err
if not path:
return Response(status=400, response="Missing path")

Expand Down Expand Up @@ -204,6 +217,7 @@ def _delete_plugin(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
if not plugin_name:
return Response(status=400, response="Missing plugin_name")
if err := self._is_valid(plugin_name): return err
try:
plugins.uninstall_plugin(plugin_name)
except FileNotFoundError as e:
Expand All @@ -219,6 +233,7 @@ def _get_default_config(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
if not plugin_name:
return Response(status=400, response="Missing plugin_name")
if err := self._is_valid(plugin_name): return err
settings = plugins.get_default_plugin_config(plugin_name)
return {"ok": True, "data": settings or {}}

Expand All @@ -230,6 +245,7 @@ def _save_config(self, input: dict) -> dict | Response:
settings = input.get("settings", {})
if not plugin_name:
return Response(status=400, response="Missing plugin_name")
if err := self._is_valid(plugin_name, project_name, agent_profile): return err
if not isinstance(settings, dict):
return Response(status=400, response="settings must be an object")
plugins.save_plugin_config(plugin_name, project_name, agent_profile, settings)
Expand All @@ -245,6 +261,7 @@ def _toggle_plugin(self, input: dict) -> dict | Response:

if not plugin_name:
return Response(status=400, response="Missing plugin_name")
if err := self._is_valid(plugin_name, project_name, agent_profile): return err
if enabled is None:
return Response(status=400, response="Missing enabled state")

Expand All @@ -259,6 +276,7 @@ def _get_doc(self, input: dict) -> dict | Response:
doc = input.get("doc", "")
if not plugin_name:
return Response(status=400, response="Missing plugin_name")
if err := self._is_valid(plugin_name): return err
if doc not in ("readme", "license"):
return Response(status=400, response="doc must be 'readme' or 'license'")

Expand All @@ -278,6 +296,7 @@ def _run_execute_script(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
if not plugin_name:
return Response(status=400, response="Missing plugin_name")
if err := self._is_valid(plugin_name): return err

plugin_dir = plugins.find_plugin_dir(plugin_name)
if not plugin_dir:
Expand Down Expand Up @@ -325,6 +344,7 @@ def _get_execute_record(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
if not plugin_name:
return Response(status=400, response="Missing plugin_name")
if err := self._is_valid(plugin_name): return err

execute_record_path = plugins.determine_plugin_asset_path(
plugin_name, "", "", "execute_record.json"
Expand Down
9 changes: 9 additions & 0 deletions helpers/extract_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ def json_parse_dirty(json: str) -> dict[str, Any] | None:

ext_json = extract_json_object_string(json.strip())
if ext_json:
# ⚡ Bolt: Try standard fast json.loads first, fallback to DirtyJson
import json as builtin_json
try:
data = builtin_json.loads(ext_json)
if isinstance(data, dict):
return data
except builtin_json.JSONDecodeError:
pass

try:
data = DirtyJson.parse_string(ext_json)
if isinstance(data, dict):
Expand Down
40 changes: 20 additions & 20 deletions helpers/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import zipfile
import glob
import mimetypes
from simpleeval import simple_eval
from simpleeval import SimpleEval
from helpers import yaml

AGENTS_DIR = "agents"
Expand All @@ -22,6 +22,18 @@
API_DIR = "api"
_base_dir = os.path.dirname(os.path.abspath(os.path.join(__file__, "../")))

# ⚡ Bolt: Performance optimization
# Pre-compiling these regular expressions at the module level avoids the overhead
# of recompiling them on every function call. Since evaluate_text_conditions,
# process_includes, and others are called frequently during prompt parsing,
# this provides a measurable performance boost.
_IF_PATTERN = re.compile(r"{{\s*if\s+(.*?)}}", flags=re.DOTALL)
_TOKEN_PATTERN = re.compile(r"{{\s*(if\b.*?|endif)\s*}}", flags=re.DOTALL)
_ORIGINAL_PATTERN = re.compile(r"{{\s*include\s+original\s*}}")
_INCLUDE_PATTERN = re.compile(r"{{\s*include\s*['\"](.*?)['\"]\s*}}")
_CODE_FENCE_PATTERN = re.compile(r"(```|~~~)(.*?\n)(.*?)(\1)", flags=re.DOTALL)
_FULL_JSON_TEMPLATE_PATTERN = re.compile(r"^\s*(```|~~~)\s*json\s*\n(.*?)\n\1\s*$", flags=re.DOTALL)

class VariablesPlugin(ABC):
@abstractmethod
def get_variables(self, file: str, backup_dirs: list[str] | None = None, **kwargs) -> dict[str, Any]: # type: ignore
Expand Down Expand Up @@ -164,18 +176,15 @@ def read_prompt_file(

def evaluate_text_conditions(_content: str, **kwargs):
# search for {{if ...}} ... {{endif}} blocks and evaluate conditions with nesting support
if_pattern = re.compile(r"{{\s*if\s+(.*?)}}", flags=re.DOTALL)
token_pattern = re.compile(r"{{\s*(if\b.*?|endif)\s*}}", flags=re.DOTALL)

def _process(text: str) -> str:
m_if = if_pattern.search(text)
m_if = _IF_PATTERN.search(text)
if not m_if:
return text

depth = 1
pos = m_if.end()
while True:
m = token_pattern.search(text, pos)
m = _TOKEN_PATTERN.search(text, pos)
if not m:
# Unterminated if-block, do not modify text
return text
Expand All @@ -191,7 +200,7 @@ def _process(text: str) -> str:
after = text[m.end() :]

try:
result = simple_eval(condition, names=kwargs)
result = SimpleEval(names=kwargs, functions={}).eval(condition)
except Exception:
# On evaluation error, do not modify this block
return text
Expand Down Expand Up @@ -337,8 +346,6 @@ def process_includes(
**kwargs,
):
# {{include original}} — include same file from lower-priority directory
original_pattern = re.compile(r"{{\s*include\s+original\s*}}")

def replace_original(match):
if not _source_file or not _source_dir:
return match.group(0)
Expand All @@ -350,11 +357,9 @@ def replace_original(match):
except FileNotFoundError:
return ""

_content = re.sub(original_pattern, replace_original, _content)
_content = _ORIGINAL_PATTERN.sub(replace_original, _content)

# {{ include 'path' }} — include a named file
include_pattern = re.compile(r"{{\s*include\s*['\"](.*?)['\"]\s*}}")

def replace_include(match):
include_path = match.group(1)
if os.path.isabs(include_path):
Expand All @@ -364,7 +369,7 @@ def replace_include(match):
except FileNotFoundError:
return match.group(0)

return re.sub(include_pattern, replace_include, _content)
return _INCLUDE_PATTERN.sub(replace_include, _content)


def _get_dirs_after(_directories: list[str], _source_dir: str) -> list[str]:
Expand Down Expand Up @@ -434,24 +439,19 @@ def find_existing_paths_by_pattern(pattern: str):


def remove_code_fences(text):
# Pattern to match code fences with optional language specifier
pattern = r"(```|~~~)(.*?\n)(.*?)(\1)"

# Function to replace the code fences
def replacer(match):
return match.group(3) # Return the code without fences

# Use re.DOTALL to make '.' match newlines
result = re.sub(pattern, replacer, text, flags=re.DOTALL)
result = _CODE_FENCE_PATTERN.sub(replacer, text)

return result


def is_full_json_template(text):
# Pattern to match the entire text enclosed in ```json or ~~~json fences
pattern = r"^\s*(```|~~~)\s*json\s*\n(.*?)\n\1\s*$"
# Use re.DOTALL to make '.' match newlines
match = re.fullmatch(pattern, text.strip(), flags=re.DOTALL)
match = _FULL_JSON_TEMPLATE_PATTERN.fullmatch(text.strip())
return bool(match)


Expand Down
29 changes: 28 additions & 1 deletion helpers/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ class PluginAssetFile(TypedDict):

_last_frontend_reload_notification_at = 0.0

def validate_plugin_name(name: str | None) -> bool:
if not name:
return False
return bool(re.fullmatch(r'^[a-zA-Z0-9_-]+$', name))

def validate_asset_name(name: str | None, allow_star: bool = False) -> bool:
if not name:
return False
if allow_star and name == "*":
return True
return bool(re.fullmatch(r'^[a-zA-Z0-9_-]+$', name))


class PluginMetadata(BaseModel):
name: str = ""
Expand Down Expand Up @@ -214,6 +226,8 @@ def clear_plugin_cache(plugin_names: list[str] | None = None):

def get_plugin_roots(plugin_name: str = "") -> List[str]:
"""Plugin root directories, ordered by priority (user first)."""
if plugin_name and not validate_plugin_name(plugin_name):
return []
return [
files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name),
files.get_abs_path(files.PLUGINS_DIR, plugin_name),
Expand Down Expand Up @@ -358,7 +372,7 @@ def get_plugin_meta(plugin_name: str):


def find_plugin_dir(plugin_name: str):
if not plugin_name:
if not validate_plugin_name(plugin_name):
return None

# check if the plugin is in the user directory
Expand Down Expand Up @@ -697,6 +711,13 @@ def find_plugin_assets(
agent_profile: str = "*",
only_first: bool = False,
) -> list[PluginAssetFile]:
if (
(plugin_name and not validate_asset_name(plugin_name, allow_star=True)) or
(project_name and not validate_asset_name(project_name, allow_star=True)) or
(agent_profile and not validate_asset_name(agent_profile, allow_star=True))
):
return []

from helpers import projects, subagents

results: list[PluginAssetFile] = []
Expand Down Expand Up @@ -808,6 +829,9 @@ def _after(s: str, marker: str, last: bool = False) -> str:
def determine_plugin_asset_path(
plugin_name: str, project_name: str, agent_profile: str, *subpaths: str
):
if not validate_plugin_name(plugin_name) or (project_name and not validate_asset_name(project_name)) or (agent_profile and not validate_asset_name(agent_profile)):
return ""

base_path = files.get_abs_path(files.USER_DIR)

if project_name:
Expand Down Expand Up @@ -866,6 +890,9 @@ async def _send_later():
def call_plugin_hook(
plugin_name: str, hook_name: str, default: Any = None, *args, **kwargs
):
if not validate_plugin_name(plugin_name):
return default

hooks = None

# use cached hooks if enabled
Expand Down
9 changes: 7 additions & 2 deletions helpers/vector_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
DistanceStrategy,
)
from langchain.embeddings import CacheBackedEmbeddings
from simpleeval import simple_eval
from simpleeval import SimpleEval

from agent import Agent
from helpers import guids
Expand Down Expand Up @@ -141,7 +141,12 @@ def cosine_normalizer(val: float) -> float:
def get_comparator(condition: str):
def comparator(data: dict[str, Any]):
try:
result = simple_eval(condition, names=data)
class SafeNames(dict):
def __missing__(self, key):
return None
safe_data = SafeNames(data)
safe_funcs = {"str": str, "int": int, "float": float, "bool": bool, "len": len, "abs": abs, "min": min, "max": max, "round": round}
result = SimpleEval(names=safe_data, functions=safe_funcs).eval(condition)
return result
except Exception as e:
# PrintStyle.error(f"Error evaluating condition: {e}")
Expand Down
9 changes: 7 additions & 2 deletions plugins/_memory/helpers/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from agent import Agent, AgentContext
import models
import logging
from simpleeval import simple_eval
from simpleeval import SimpleEval


# Raise the log level so WARNING messages aren't shown
Expand Down Expand Up @@ -437,7 +437,12 @@ def _save_db_file(db: MyFaiss, memory_subdir: str):
def _get_comparator(condition: str):
def comparator(data: dict[str, Any]):
try:
result = simple_eval(condition, names=data)
class SafeNames(dict):
def __missing__(self, key):
return None
safe_data = SafeNames(data)
safe_funcs = {"str": str, "int": int, "float": float, "bool": bool, "len": len, "abs": abs, "min": min, "max": max, "round": round}
result = SimpleEval(names=safe_data, functions=safe_funcs).eval(condition)
return result
except Exception as e:
PrintStyle.error(f"Error evaluating condition: {e}")
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ GitPython==3.1.43
giturlparse==0.14.0
inputimeout==1.0.4
kokoro>=0.9.2
simpleeval==1.0.3
simpleeval>=1.0.5 # CVE-2026-32640 fix: sandbox escape/code injection
langchain-core==0.3.49
langchain-community==0.3.19
langchain-unstructured==0.1.6
Expand Down
Loading