diff --git a/docker/Dockerfile.chat b/docker/Dockerfile.chat index 3ca573be4..4de234a21 100644 --- a/docker/Dockerfile.chat +++ b/docker/Dockerfile.chat @@ -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.5.113" \ + "praisonai>=4.5.114" \ "praisonai[chat]" \ "embedchain[github,youtube]" diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 65741f137..f81eb1fe1 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -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.5.113" \ + "praisonai>=4.5.114" \ "praisonai[ui]" \ "praisonai[chat]" \ "praisonai[realtime]" \ diff --git a/docker/Dockerfile.ui b/docker/Dockerfile.ui index 8485ee7ad..25c6ac0de 100644 --- a/docker/Dockerfile.ui +++ b/docker/Dockerfile.ui @@ -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.5.113" \ + "praisonai>=4.5.114" \ "praisonai[ui]" \ "praisonai[crewai]" diff --git a/src/praisonai-agents/praisonaiagents/context/ledger.py b/src/praisonai-agents/praisonaiagents/context/ledger.py index 10e62c431..790dc2647 100644 --- a/src/praisonai-agents/praisonaiagents/context/ledger.py +++ b/src/praisonai-agents/praisonaiagents/context/ledger.py @@ -291,7 +291,20 @@ def __init__(self): self._shared_tokens: int = 0 # Tokens shared across agents def get_agent_ledger(self, agent_id: str) -> ContextLedgerManager: - """Get or create ledger for an agent.""" + """Get or create ledger for an agent. + + Args: + agent_id: Unique identifier for the agent. + + Returns: + ContextLedgerManager for this agent. + + Raises: + ValueError: If agent_id is empty or contains only whitespace. + """ + if not agent_id or not agent_id.strip(): + raise ValueError("agent_id must be a non-empty string") + if agent_id not in self._agents: self._agents[agent_id] = ContextLedgerManager(agent_id) return self._agents[agent_id] diff --git a/src/praisonai-agents/praisonaiagents/context/monitor.py b/src/praisonai-agents/praisonaiagents/context/monitor.py index 1923cb960..ddc1fc97d 100644 --- a/src/praisonai-agents/praisonaiagents/context/monitor.py +++ b/src/praisonai-agents/praisonaiagents/context/monitor.py @@ -514,7 +514,10 @@ def snapshot( return str(output_path) def _get_output_path(self, agent_name: str = "") -> Path: - """Get output path, optionally per-agent.""" + """Get output path, optionally per-agent. + + Security: Agent names are sanitized to prevent path traversal. + """ if not self.multi_agent_files or not agent_name: return self.path @@ -522,10 +525,30 @@ def _get_output_path(self, agent_name: str = "") -> Path: stem = self.path.stem suffix = self.path.suffix or (".json" if self.format == "json" else ".txt") - # Sanitize agent name - safe_name = re.sub(r'[^\w\-]', '_', agent_name.lower()) - - return self.path.parent / f"{stem}_{safe_name}{suffix}" + # Sanitize agent name: + # 1. Strip any path separators first (defence in depth) + safe_name = agent_name.replace('/', '_').replace('\\', '_') + # 2. Collapse to safe chars only + safe_name = re.sub(r'[^\w\-]', '_', safe_name.lower()) + # 3. Enforce length limit to prevent filesystem issues + safe_name = safe_name[:64] if safe_name else 'unknown' + # 4. Strip leading/trailing underscores / dots + safe_name = safe_name.strip('_.') + if not safe_name: + safe_name = 'unknown' + + result = self.path.parent / f"{stem}_{safe_name}{suffix}" + + # Verify the resolved path stays within the expected parent dir + resolved = result.resolve() + parent_resolved = self.path.parent.resolve() + if not str(resolved).startswith(str(parent_resolved) + '/') and resolved.parent != parent_resolved: + # Fallback to a safe hash-based name + import hashlib + hash_name = hashlib.sha256(agent_name.encode()).hexdigest()[:16] + result = self.path.parent / f"{stem}_{hash_name}{suffix}" + + return result def _get_warnings( self, diff --git a/src/praisonai-agents/praisonaiagents/tools/python_tools.py b/src/praisonai-agents/praisonaiagents/tools/python_tools.py index 59b5680ce..9fa9056bd 100644 --- a/src/praisonai-agents/praisonaiagents/tools/python_tools.py +++ b/src/praisonai-agents/praisonaiagents/tools/python_tools.py @@ -143,7 +143,15 @@ def safe_execute(): blocked_attrs = {{ '__subclasses__', '__bases__', '__mro__', '__globals__', '__code__', '__class__', '__dict__', '__builtins__', - '__import__', '__loader__', '__spec__' + '__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', }} for node in ast.walk(tree): @@ -162,7 +170,9 @@ def safe_execute(): "success": False }} if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): - if node.func.id in ('exec', 'eval', 'compile', '__import__', 'open'): + if node.func.id in ('exec', 'eval', 'compile', '__import__', + 'open', 'input', 'breakpoint', + 'setattr', 'delattr', 'dir'): return {{ "result": None, "stdout": "", diff --git a/src/praisonai-agents/pyproject.toml b/src/praisonai-agents/pyproject.toml index 2d7b49af5..a90782880 100644 --- a/src/praisonai-agents/pyproject.toml +++ b/src/praisonai-agents/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "praisonaiagents" -version = "1.5.113" +version = "1.5.114" description = "Praison AI agents for completing complex tasks with Self Reflection Agents" readme = "README.md" requires-python = ">=3.10" diff --git a/src/praisonai-agents/uv.lock b/src/praisonai-agents/uv.lock index b855a3878..923e1c420 100644 --- a/src/praisonai-agents/uv.lock +++ b/src/praisonai-agents/uv.lock @@ -2951,7 +2951,7 @@ wheels = [ [[package]] name = "praisonaiagents" -version = "1.5.113" +version = "1.5.114" source = { editable = "." } dependencies = [ { name = "aiohttp" }, diff --git a/src/praisonai-ts/src/workflows/yaml-parser.ts b/src/praisonai-ts/src/workflows/yaml-parser.ts index 83bf338f5..5a242a506 100644 --- a/src/praisonai-ts/src/workflows/yaml-parser.ts +++ b/src/praisonai-ts/src/workflows/yaml-parser.ts @@ -4,6 +4,7 @@ */ import { AgentFlow, Task, TaskConfig } from './index'; +import * as path from 'path'; export interface YAMLWorkflowDefinition { name: string; @@ -39,8 +40,9 @@ export interface ParsedWorkflow { * Parse YAML string into workflow definition */ export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition { - // Simple YAML parser for workflow definitions - // For production, use js-yaml package + // SECURITY: If migrating to js-yaml, you MUST use: + // yaml.load(content, { schema: yaml.JSON_SCHEMA }) + // Never use yaml.load() with DEFAULT_SCHEMA — it enables arbitrary JS execution. const lines = yamlContent.split('\n'); const result: YAMLWorkflowDefinition = { name: '', @@ -82,7 +84,15 @@ export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition { type: 'agent' }; } else if (currentStep) { - // Step properties + // Step properties — whitelist allowed keys to prevent injection + const ALLOWED_STEP_KEYS = new Set([ + 'type', 'agent', 'tool', 'input', 'output', 'condition', + 'onError', 'maxRetries', 'timeout', 'loopCondition', 'maxIterations', + ]); + if (!ALLOWED_STEP_KEYS.has(key)) { + // Ignore unknown keys — do not allow arbitrary property injection + continue; + } if (key === 'type') currentStep.type = value as any; else if (key === 'agent') currentStep.agent = value; else if (key === 'tool') currentStep.tool = value; @@ -265,9 +275,33 @@ function parseValue(value: string): any { export async function loadWorkflowFromFile( filePath: string, agents: Record = {}, - tools: Record = {} + tools: Record = {}, + options: { basePath?: string; maxFileSizeBytes?: number } = {} ): Promise { const fs = await import('fs/promises'); + + // SECURITY: Prevent path traversal + const normalizedPath = path.normalize(filePath); + if (normalizedPath.includes('..')) { + throw new Error('Path traversal detected: ".." is not allowed in file paths'); + } + + // If basePath is specified, ensure resolvedPath stays within it + if (options.basePath) { + const resolvedBase = path.resolve(options.basePath); + const resolvedFile = path.resolve(options.basePath, normalizedPath); + if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) { + throw new Error(`File path must be within base directory: ${options.basePath}`); + } + } + + // SECURITY: Enforce file size limit (default 1 MB) + const maxSize = options.maxFileSizeBytes ?? 1_048_576; + const stat = await fs.stat(filePath); + if (stat.size > maxSize) { + throw new Error(`File too large: ${stat.size} bytes exceeds limit of ${maxSize} bytes`); + } + const content = await fs.readFile(filePath, 'utf-8'); const definition = parseYAMLWorkflow(content); return createWorkflowFromYAML(definition, agents, tools); diff --git a/src/praisonai/praisonai.rb b/src/praisonai/praisonai.rb index a1987f6cc..acaa0729a 100644 --- a/src/praisonai/praisonai.rb +++ b/src/praisonai/praisonai.rb @@ -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.5.113.tar.gz" - sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.113.tar.gz | shasum -a 256`.split.first + url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.114.tar.gz" + sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.114.tar.gz | shasum -a 256`.split.first license "MIT" depends_on "python@3.11" diff --git a/src/praisonai/praisonai/cli/features/agent_tools.py b/src/praisonai/praisonai/cli/features/agent_tools.py index 2c86e4633..d022b9f53 100644 --- a/src/praisonai/praisonai/cli/features/agent_tools.py +++ b/src/praisonai/praisonai/cli/features/agent_tools.py @@ -15,9 +15,70 @@ from .code_intelligence import CodeIntelligenceRouter from .action_orchestrator import ActionOrchestrator +import os +import re + logger = logging.getLogger(__name__) +def _sanitize_filepath(filepath: str, workspace: str = None) -> str: + """Validate and sanitize a filepath against injection attacks. + + Raises ValueError if the path is unsafe. + """ + # Reject null bytes (common injection technique) + if '\x00' in filepath: + raise ValueError("Null bytes are not allowed in file paths") + + # Reject obvious shell meta-characters in file names + if any(ch in filepath for ch in ('|', ';', '`', '$', '&', '\n', '\r')): + raise ValueError(f"Unsafe characters in filepath: {filepath!r}") + + # Normalize and reject path traversal + normalized = os.path.normpath(filepath) + if '..' in normalized.split(os.sep): + raise ValueError(f"Path traversal detected in: {filepath!r}") + + # If we have a workspace, ensure the resolved path stays within it + if workspace: + resolved = os.path.realpath(os.path.join(workspace, normalized)) + ws_resolved = os.path.realpath(workspace) + if not resolved.startswith(ws_resolved + os.sep) and resolved != ws_resolved: + raise ValueError( + f"Path {filepath!r} resolves outside workspace {workspace!r}" + ) + + return normalized + + +def _sanitize_command(command: str) -> str: + """Basic command sanitization — reject common injection vectors. + + The command still goes through ACP approval flow, but this prevents + the most egregious prompt-injection-to-shell-injection chains. + + Raises ValueError if dangerous patterns are detected. + """ + if '\x00' in command: + raise ValueError("Null bytes are not allowed in commands") + + # Reject command chaining operators that indicate injection + DANGEROUS_PATTERNS = [ + '$(', '`', # Command substitution + '&&', '||', # Command chaining + '>>', '> ', # Output redirection + '| ', # Pipe + ] + for pattern in DANGEROUS_PATTERNS: + if pattern in command: + raise ValueError( + f"Potentially unsafe command pattern detected: {pattern!r} " + f"in command: {command!r}. Use separate commands instead." + ) + + return command + + def create_agent_centric_tools( runtime: "InteractiveRuntime", router: "CodeIntelligenceRouter" = None, @@ -77,8 +138,17 @@ def acp_create_file(filepath: str, content: str) -> str: JSON string with result including plan status and verification """ async def _create(): + # Validate filepath + try: + safe_path = _sanitize_filepath( + filepath, + workspace=getattr(runtime.config, 'workspace', None) + ) + except ValueError as e: + return json.dumps({"success": False, "error": str(e)}) + # Build a detailed prompt for the orchestrator - prompt = f"create file {filepath}" + prompt = f"create file {safe_path}" # Create plan result = await orchestrator.create_plan(prompt) @@ -141,7 +211,15 @@ def acp_edit_file(filepath: str, new_content: str) -> str: JSON string with result including plan status """ async def _edit(): - prompt = f"edit file {filepath}" + try: + safe_path = _sanitize_filepath( + filepath, + workspace=getattr(runtime.config, 'workspace', None) + ) + except ValueError as e: + return json.dumps({"success": False, "error": str(e)}) + + prompt = f"edit file {safe_path}" result = await orchestrator.create_plan(prompt) if not result.success: @@ -189,7 +267,15 @@ def acp_delete_file(filepath: str) -> str: JSON string with result """ async def _delete(): - prompt = f"delete file {filepath}" + try: + safe_path = _sanitize_filepath( + filepath, + workspace=getattr(runtime.config, 'workspace', None) + ) + except ValueError as e: + return json.dumps({"success": False, "error": str(e)}) + + prompt = f"delete file {safe_path}" result = await orchestrator.create_plan(prompt) if not result.success: @@ -234,7 +320,12 @@ def acp_execute_command(command: str, cwd: str = None) -> str: JSON string with command output """ async def _execute(): - prompt = f"run command: {command}" + try: + safe_cmd = _sanitize_command(command) + except ValueError as e: + return json.dumps({"success": False, "error": str(e)}) + + prompt = f"run command: {safe_cmd}" result = await orchestrator.create_plan(prompt) if not result.success: @@ -414,10 +505,16 @@ def read_file(filepath: str) -> str: if not path.is_absolute(): path = Path(runtime.config.workspace) / filepath - if not path.exists(): + # SECURITY: Ensure resolved path stays within workspace + resolved = path.resolve() + ws_resolved = Path(runtime.config.workspace).resolve() + if not str(resolved).startswith(str(ws_resolved) + os.sep) and resolved != ws_resolved: + return json.dumps({"error": f"Path escapes workspace: {filepath}"}) + + if not resolved.exists(): return json.dumps({"error": f"File not found: {filepath}"}) - content = path.read_text() + content = resolved.read_text() # Track in trace if enabled if runtime._trace: @@ -451,7 +548,13 @@ def list_files(directory: str = ".", pattern: str = "*") -> str: if not path.is_absolute(): path = Path(runtime.config.workspace) / directory - if not path.exists(): + # SECURITY: Ensure resolved path stays within workspace + resolved = path.resolve() + ws_resolved = Path(runtime.config.workspace).resolve() + if not str(resolved).startswith(str(ws_resolved) + os.sep) and resolved != ws_resolved: + return json.dumps({"error": f"Directory escapes workspace: {directory}"}) + + if not resolved.exists(): return json.dumps({"error": f"Directory not found: {directory}"}) files = [] diff --git a/src/praisonai/praisonai/deploy.py b/src/praisonai/praisonai/deploy.py index 0ca0e7d98..b5bbd2e3f 100644 --- a/src/praisonai/praisonai/deploy.py +++ b/src/praisonai/praisonai/deploy.py @@ -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.5.113 gunicorn markdown\n") + file.write("RUN pip install flask praisonai==4.5.114 gunicorn markdown\n") file.write("EXPOSE 8080\n") file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n') diff --git a/src/praisonai/praisonai/endpoints/a2u_server.py b/src/praisonai/praisonai/endpoints/a2u_server.py index 0c423edab..1a7dd0ac2 100644 --- a/src/praisonai/praisonai/endpoints/a2u_server.py +++ b/src/praisonai/praisonai/endpoints/a2u_server.py @@ -7,6 +7,7 @@ import asyncio import json import logging +import os import uuid from dataclasses import dataclass, field from datetime import datetime, timezone @@ -240,8 +241,36 @@ def create_a2u_routes(app: Any, event_bus: Optional[A2UEventBus] = None) -> None except ImportError: raise ImportError("Starlette or FastAPI required for A2U routes") + def _authenticate_request(request) -> Optional[JSONResponse]: + """Check bearer token auth when A2U_AUTH_TOKEN is configured. + + Returns None if authenticated, or a 401/403 JSONResponse otherwise. + """ + auth_token = os.environ.get("A2U_AUTH_TOKEN") + if not auth_token: + # No token configured — auth disabled (development mode) + return None + + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + return JSONResponse( + {"error": "Authentication required. Set Authorization: Bearer "}, + status_code=401, + ) + + import hmac + provided = auth_header[7:] # strip "Bearer " + if not hmac.compare_digest(provided, auth_token): + return JSONResponse( + {"error": "Invalid authentication token"}, + status_code=403, + ) + async def a2u_info(request): """GET /a2u/info - Get A2U server info.""" + auth_error = _authenticate_request(request) + if auth_error: + return auth_error return JSONResponse({ "name": "A2U Event Stream", "version": "1.0.0", @@ -258,6 +287,9 @@ async def a2u_info(request): async def a2u_subscribe(request): """POST /a2u/subscribe - Subscribe to an event stream.""" + auth_error = _authenticate_request(request) + if auth_error: + return auth_error try: body = await request.json() except Exception: @@ -279,6 +311,9 @@ async def a2u_subscribe(request): async def a2u_unsubscribe(request): """POST /a2u/unsubscribe - Unsubscribe from an event stream.""" + auth_error = _authenticate_request(request) + if auth_error: + return auth_error try: body = await request.json() except Exception: @@ -295,6 +330,9 @@ async def a2u_unsubscribe(request): async def a2u_events_stream(request): """GET /a2u/events/{stream_name} - Stream events via SSE.""" + auth_error = _authenticate_request(request) + if auth_error: + return auth_error stream_name = request.path_params.get("stream_name", "events") # Create subscription for this stream @@ -319,6 +357,9 @@ async def event_generator(): async def a2u_events_subscription(request): """GET /a2u/events/sub/{subscription_id} - Stream events for subscription.""" + auth_error = _authenticate_request(request) + if auth_error: + return auth_error subscription_id = request.path_params.get("subscription_id") if subscription_id not in bus._subscriptions: diff --git a/src/praisonai/praisonai/version.py b/src/praisonai/praisonai/version.py index 551587ade..006906f8d 100644 --- a/src/praisonai/praisonai/version.py +++ b/src/praisonai/praisonai/version.py @@ -1 +1 @@ -__version__ = "4.5.113" \ No newline at end of file +__version__ = "4.5.114" \ No newline at end of file diff --git a/src/praisonai/pyproject.toml b/src/praisonai/pyproject.toml index 2fae8aa12..f2bf38d3d 100644 --- a/src/praisonai/pyproject.toml +++ b/src/praisonai/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "rich>=13.7", "markdown>=3.5", "pyparsing>=3.0.0", - "praisonaiagents>=1.5.113", + "praisonaiagents>=1.5.114", "python-dotenv>=0.19.0", "litellm>=1.81.0,<=1.82.6", "PyYAML>=6.0", diff --git a/src/praisonai/uv.lock b/src/praisonai/uv.lock index 2a1b15b10..0cc4218d0 100644 --- a/src/praisonai/uv.lock +++ b/src/praisonai/uv.lock @@ -5117,7 +5117,7 @@ wheels = [ [[package]] name = "praisonaiagents" -version = "1.5.113" +version = "1.5.114" source = { directory = "../praisonai-agents" } dependencies = [ { name = "aiohttp" },