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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ssh": "pip install praisonai-sandbox[ssh]",
"modal": "pip install praisonai-sandbox[modal]",
"daytona": "pip install praisonai-sandbox[daytona]",
"novita": "pip install praisonai-sandbox[novita]",
}


Expand Down
7 changes: 7 additions & 0 deletions src/praisonai-agents/praisonaiagents/sandbox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ def e2b(cls) -> "SandboxConfig":
sandbox_type="e2b",
)

@classmethod
def novita(cls) -> "SandboxConfig":
"""Create a Novita sandbox configuration."""
return cls(
sandbox_type="novita",
)

@classmethod
def capsule(cls) -> "SandboxConfig":
"""Create a Capsule sandbox configuration.
Expand Down
2 changes: 1 addition & 1 deletion src/praisonai-sandbox/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# praisonai-sandbox

Isolated agent code execution for PraisonAI — Docker, subprocess, E2B, Modal, Sandlock, and SSH backends.
Isolated agent code execution for PraisonAI — Docker, subprocess, E2B, Modal, Novita, Sandlock, and SSH backends.

## Install

Expand Down
7 changes: 6 additions & 1 deletion src/praisonai-sandbox/praisonai_sandbox/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
Sandbox implementations for PraisonAI.

Provides Docker, subprocess, sandlock, SSH, Modal, and Daytona sandbox for safe code execution.
Provides Docker, subprocess, sandlock, SSH, Modal, Daytona, E2B, and Novita sandbox for safe code execution.
"""

from typing import TYPE_CHECKING
Expand All @@ -16,6 +16,7 @@
from .modal import ModalSandbox
from .daytona import DaytonaSandbox
from .e2b import E2BSandbox
from .novita import NovitaSandbox


def __getattr__(name: str):
Expand All @@ -41,6 +42,9 @@ def __getattr__(name: str):
if name == "E2BSandbox":
from .e2b import E2BSandbox
return E2BSandbox
if name == "NovitaSandbox":
from .novita import NovitaSandbox
return NovitaSandbox
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


Expand All @@ -52,5 +56,6 @@ def __getattr__(name: str):
"ModalSandbox",
"DaytonaSandbox",
"E2BSandbox",
"NovitaSandbox",
"__version__",
]
6 changes: 6 additions & 0 deletions src/praisonai-sandbox/praisonai_sandbox/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ def _e2b_loader():
return E2BSandbox


def _novita_loader():
from .novita import NovitaSandbox
return NovitaSandbox


# Built-in sandbox types with lazy loading
_BUILTIN_SANDBOXES = {
"docker": _docker_loader,
Expand All @@ -54,6 +59,7 @@ def _e2b_loader():
"modal": _modal_loader,
"daytona": _daytona_loader,
"e2b": _e2b_loader,
"novita": _novita_loader,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Novita install hint omitted

Registering novita without a corresponding _EXTRA_HINTS entry means an unavailable backend recommends only pip install praisonai-sandbox, which does not install novita-sandbox and leaves the backend unavailable.

Knowledge Base Used: praisonai-sandbox

}


Expand Down
302 changes: 302 additions & 0 deletions src/praisonai-sandbox/praisonai_sandbox/novita.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
"""
Novita Sandbox implementation for PraisonAI.

Provides code execution in Novita cloud sandboxes via novita-sandbox.
"""

from __future__ import annotations

import logging
import os
import shlex
import time
import uuid
from typing import Any, Dict, List, Optional, Union

from praisonaiagents.sandbox import (
SandboxConfig,
SandboxResult,
SandboxStatus,
ResourceLimits,
)

logger = logging.getLogger(__name__)

_INSTALL_HINT = "pip install praisonai-sandbox[novita]"


class NovitaSandbox:
"""Novita cloud sandbox for isolated code execution.

Example:
import os
from praisonai_sandbox import NovitaSandbox

os.environ["NOVITA_API_KEY"] = "your-api-key"
sandbox = NovitaSandbox()
result = await sandbox.execute("print('Hello, World!')")
print(result.stdout) # Hello, World!

Requires:
- pip install novita-sandbox
- NOVITA_API_KEY environment variable
"""

def __init__(self, config: Optional[SandboxConfig] = None):
"""Initialize the Novita sandbox.

Args:
config: Optional sandbox configuration
"""
self.config = config or SandboxConfig(sandbox_type="novita")
self._sandbox = None
self._is_running = False

@property
def is_available(self) -> bool:
"""Check if Novita is available."""
try:
import importlib
importlib.import_module("novita_sandbox")
api_key = os.getenv("NOVITA_API_KEY")
return api_key is not None and api_key.strip() != ""
except ImportError:
return False

@property
def sandbox_type(self) -> str:
return "novita"

async def start(self) -> None:
"""Start/initialize the sandbox environment."""
if self._is_running:
return

if not self.is_available:
raise RuntimeError(
f"Novita is not available. Please install novita-sandbox and set NOVITA_API_KEY. {_INSTALL_HINT}"
)

try:
from novita_sandbox.core import AsyncSandbox
except ImportError:
raise ImportError(
"novita-sandbox not installed. "
"Install with: pip install novita-sandbox"
)

self._sandbox = await AsyncSandbox.create(
timeout=self.config.resource_limits.timeout_seconds,
)
self._is_running = True
logger.info("Novita sandbox initialized")
Comment on lines +70 to +92

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate novita.py =="
fd -a 'novita\.py$' . || true

echo "== file stats outline =="
file="$(fd 'novita\.py$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  ast-grep outline "$file" --view expanded || true
  echo "== relevant lines 1-130 =="
  sed -n '1,130p' "$file" | nl -ba
fi

echo "== search for ConcurrentStart tests / locks / startup patterns =="
rg -n "concurrent|Concurrent|_running|AsyncSandbox\.create|asyncio\.Lock|test_|StartAsync|Novita.*Sandbox" -S .

Repository: MervinPraison/PraisonAI

Length of output: 1193


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'novita\.py$' . | sed '2q;d')"
test_file="$(fd 'test_novita\.py$' . | sed '2q;d')"

echo "== novita.py 1-130 =="
sed -n '1,130p' "$file" | awk '{printf "%4d\t%s\n", NR, $0}'

echo "== tests/search relevant =="
rg -n "concurrent|Concurrent|_running|AsyncSandbox\.create|asyncio\.Lock|Mock|start\(" "$file" "$test_file" -S

echo "== stop/create usages async =="
sed -n '130,302p' "$file" | awk '{printf "%4d\t%s\n", NR+129, $0}'

Repository: MervinPraison/PraisonAI

Length of output: 6137


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'novita\.py$' . | sed '2q;d')"
test_file="$(fd 'test_novita\.py$' . | sed '2q;d')"

echo "== novita.py full =="
python3 - <<'PY' "$file"
import sys
for i, line in enumerate(open(sys.argv[1], encoding="utf-8"), 1):
    print(f"{i:4}\t{line}", end="")
PY

echo "== concurrent start probe =="
python3 - <<'PY'
import asyncio

class NovitaLike:
    def __init__(self):
        self._is_running = False
        self._sandbox = None
        self.create_count = 0

    def _is_running_check(self):
        return self._is_running

    async def _set_running(self):
        await asyncio.sleep(0.001)
        self._sandbox = f"started:{id(asyncio.current_task())}"
        self.create_count += 1
        self._is_running = True

    async def start_unprotected(self):
        if self._is_running_check():
            return
        await self._set_running()

async def main():
    for i in range(3):
        sandbox = NovitaLike()
        await asyncio.gather(sandbox.start_unprotected(), sandbox.start_unprotected())
        print(f"attempt={i} create_count={sandbox.create_count} sandbox={sandbox._sandbox} running={sandbox._is Running_check()}")

asyncio.run(main())
PY

echo "== imports/locks in novita and tests =="
pgrep -a pgrep | head -n1 || true
python3 - <<'PY'
from pathlib import Path
for p in [Path("src/praisonai-sandbox/praisonai_sandbox/novita.py"), Path("src/praisonai-sandbox/tests/test_novita.py")]:
    print(f"--- {p} ---")
    text = p.read_text(encoding="utf-8")
    for token in ["asyncio", "Lock", "create_task", "task_group", "concurrent", "start", "AsyncSandbox.create"]:
        if token in text:
            print(token)
PY

Repository: MervinPraison/PraisonAI

Length of output: 4419


🏁 Script executed:

#!/bin/bash
set -euo pipefail

src="src/praisonai-sandbox/praisonai_sandbox/novita.py"
test="src/praisonai-sandbox/tests/test_novita.py"

for p in "$src" "$test"; do
  echo "== full $p =="
  python3 - "$p" <<'PY'
import sys
for i, line in enumerate(open(sys.argv[1], encoding="utf-8"), 1):
    print(f"{i:4}\t{line}", end="")
PY
done

echo "== concurrent state race probe =="
python3 - <<'PY'
import asyncio

class NovitaLike:
    def __init__(self):
        self._is_running = False
        self._sandbox = None
        self.create_count = 0

    async def _set_running(self):
        await asyncio.sleep(0.001)
        self._sandbox = f"started:{id(asyncio.current_task())}"
        self.create_count += 1
        self._is_running = True

    async def start_unprotected(self):
        if self._is_running:
            return
        await self._set_running()

async def main():
    for attempt in range(1):
        sandbox = NovitaLike()
        await asyncio.gather(sandbox.start_unprotected(), sandbox.start_unprotected())
        print(f"create_count={sandbox.create_count} sandbox={sandbox._sandbox} is_running={sandbox._is_running}")

asyncio.run(main())
PY

Repository: MervinPraison/PraisonAI

Length of output: 15263


Serialize concurrent startup.

The _is_running check in start() happens before AsyncSandbox.create() awaits. Simultaneous await sandbox.start() calls can both leave the branch and create separate sandboxes; only the last self._sandbox remains killable. Protect startup with one per-instance asyncio.Lock and add a concurrent-start test that asserts exactly one AsyncSandbox.create() call.

🧰 Tools
🪛 Ruff (0.16.1)

[warning] 83-86: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/praisonai-sandbox/praisonai_sandbox/novita.py` around lines 70 - 92,
Update the Novita sandbox initialization around start() to use a per-instance
asyncio.Lock, rechecking _is_running after acquiring it so concurrent calls
serialize and only one AsyncSandbox.create() executes. Initialize the lock with
the instance state, preserve the existing availability and import validation,
and add a concurrent-start test asserting exactly one AsyncSandbox.create()
call.


async def stop(self) -> None:
"""Stop/cleanup the sandbox environment."""
if not self._is_running:
return

if self._sandbox:
try:
await self._sandbox.kill()
except Exception as e:
logger.warning(f"Failed to kill Novita sandbox: {e}")
self._sandbox = None

self._is_running = False
logger.info("Novita sandbox stopped")

async def execute(
self,
code: str,
language: str = "python",
limits: Optional[ResourceLimits] = None,
env: Optional[Dict[str, str]] = None,
working_dir: Optional[str] = None,
) -> SandboxResult:
"""Execute code in the sandbox."""
if not self._is_running:
await self.start()

limits = limits or self.config.resource_limits
execution_id = str(uuid.uuid4())
started_at = time.time()

if language == "python":
command = f"python3 -c {shlex.quote(code)}"
elif language == "bash":
command = code
else:
command = f"python3 -c {shlex.quote(code)}"

return await self._run_command(
command,
limits=limits,
env=env,
working_dir=working_dir,
execution_id=execution_id,
started_at=started_at,
)

async def run_command(
self,
command: Union[str, List[str]],
limits: Optional[ResourceLimits] = None,
env: Optional[Dict[str, str]] = None,
working_dir: Optional[str] = None,
) -> SandboxResult:
"""Run a shell command in the sandbox."""
if not self._is_running:
await self.start()

if isinstance(command, list):
command = shlex.join(command)

limits = limits or self.config.resource_limits
return await self._run_command(
command,
limits=limits,
env=env,
working_dir=working_dir,
execution_id=str(uuid.uuid4()),
started_at=time.time(),
)

async def _run_command(
self,
command: str,
*,
limits: ResourceLimits,
env: Optional[Dict[str, str]],
working_dir: Optional[str],
execution_id: str,
started_at: float,
) -> SandboxResult:
try:
result = await self._sandbox.commands.run(
command,
envs=env,
cwd=working_dir,
timeout=limits.timeout_seconds,
Comment on lines +176 to +180

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Apply SandboxConfig defaults before the SDK call.

execute() and run_command() pass None when callers omit env or working_dir. _run_command() forwards those values, so SandboxConfig.env and SandboxConfig.working_dir have no effect. Merge configured environment variables with call-specific values, with call-specific keys taking precedence. Default cwd to the configured working directory. Add coverage for both entry points.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/praisonai-sandbox/praisonai_sandbox/novita.py` around lines 176 - 180,
Update execute(), run_command(), and _run_command() to apply SandboxConfig.env
and SandboxConfig.working_dir before invoking _sandbox.commands.run. Merge
configured and call-specific environment variables with call-specific keys
taking precedence, default cwd to the configured working directory when
working_dir is omitted, and add coverage for both public entry points.

)
status = SandboxStatus.COMPLETED if result.exit_code == 0 else SandboxStatus.FAILED
return SandboxResult(
execution_id=execution_id,
status=status,
exit_code=result.exit_code,
stdout=result.stdout or "",
stderr=result.stderr or "",
error=result.error,
duration_seconds=time.time() - started_at,
started_at=started_at,
completed_at=time.time(),
)
except Exception as exc:
error = str(exc)
status = SandboxStatus.TIMEOUT if "timeout" in error.lower() else SandboxStatus.FAILED
return SandboxResult(
execution_id=execution_id,
status=status,
error=error,
duration_seconds=time.time() - started_at,
started_at=started_at,
completed_at=time.time(),
)

async def execute_file(
self,
file_path: str,
args: Optional[List[str]] = None,
limits: Optional[ResourceLimits] = None,
env: Optional[Dict[str, str]] = None,
) -> SandboxResult:
"""Execute a file in the sandbox."""
try:
with open(file_path, "r") as f:
code = f.read()
Comment on lines +214 to +216

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate file and inspect context:"
fd -a 'novita.py' . | sed 's#^\./##'
file=$(fd 'novita.py' . | head -n1)
if [ -n "${file:-}" ]; then
  wc -l "$file"
  ast-grep outline "$file" || true
  echo "=== relevant lines 1-260 ==="
  sed -n '1,260p' "$file" | nl -ba
fi

echo "=== searches for execute_file and related config/default params ==="
rg -n "def execute_file|execute_file\\(|file_path|allowlist|allowed|sandbox|SandboxConfig|working_dir|env\\)" .

Repository: MervinPraison/PraisonAI

Length of output: 810


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=$(fd 'novita.py' . | head -n1)
echo "file=$file"

echo "=== relevant lines 1-280 ==="
sed -n '1,280p' "$file" | awk '{printf "%5d\t%s\n", NR, $0}'

echo "=== execute_file definition and callers/test snippets ==="
rg -n -C 6 "def execute_file|execute_file\\(|file_path|allowed|allowlist|working_dir" .

Repository: MervinPraison/PraisonAI

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=$(fd 'novita.py' . | head -n1)
test_file=$(fd 'test_novita.py' . | head -n1)

echo "=== novita.py execute_file section ==="
sed -n '180,270p' "$file" | awk '{printf "%5d\t%s\n", NR+179, $0}'

echo "=== novita.py init/defaults section ==="
sed -n '28,130p' "$file" | awk '{printf "%5d\t%s\n", NR+27, $0}'

echo "=== novita.py execute_file signature/imports relevant ==="
sed -n '1,40p' "$file" | awk '{printf "%5d\t%s\n", NR, $0}'

echo "=== test_novita.py execute_file calls ==="
if [ -n "${test_file:-}" ]; then
  rg -n -C 8 "execute_file\\(|def test_|file_path|working_dir" "$test_file"
fi

echo "=== scoped policy/search in praisonai-sandbox ==="
sed -n '1,280p' "$file" | awk '{printf "%5d\t%s\n", NR, $0}' | rg -n "file_path|allowed|allowlist|working_dir|file path|sandbox|execute_file"

Repository: MervinPraison/PraisonAI

Length of output: 15552


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

path = Path("src/praisonai-sandbox/praisonai_sandbox/novita.py")
tree = ast.parse(path.read_text())

for cls in tree.body:
    if isinstance(cls, ast.ClassDef) and cls.name == "NovitaSandbox":
        for fn in cls.body:
            if isinstance(fn, ast.AsyncFunctionDef) and fn.name in {"__init__", "execute_file"}:
                print(f"{fn.name}:")
                print(f"  signature={ast.dump(ast.Module(body=[fn], type_ignores=[]), include_attributes=False)}")
                opens = []
                for node in ast.walk(fn):
                    if isinstance(node, ast.With) and any(
                        isinstance(t.context_expr, ast.Name) and t.context_expr.id == "open"
                        for t in node.items
                    ):
                        opens.append(node)
                print(f"  open_statements={len(opens)}")
                for with_node in opens:
                    open_call = next(
                        node
                        for t in with_node.items
                        if isinstance(t.context_expr, ast.Call)
                        and isinstance(t.context_expr.func, ast.Name)
                        and t.context_expr.func.id == "open"
                        for node in [t]
                    )
                    call = open_call.context_expr
                    print(f"  open_file_arg={ast.dump(call.args[0]) if call.args else None}")
                    print(f"  open_mode_arg={ast.dump(call.args[1]) if len(call.args) > 1 else None}")
                    print(f"  open_kwargs_arg={ast.dump(call.keywords[0].value) if call.keywords and call.keywords[0].arg == 'mode' else None}")
PY

echo "=== read-only path normalization probe using Python stdlib only ==="
python3 - <<'PY'
import os
print("os.getcwd:", os.getcwd())
print("os.path.abspath('../../secret.py')", os.path.abspath(os.path.join("/app/praisonai/nov", "../../secret.py")))
print("os.path.realpath", os.path.realpath(os.path.join("/app/praisonai/nov", "../../secret.py")))
print("commonprefix example", os.path.commonprefix(["/app/work/project", os.path.abspath(os.path.join("/app/praisonai/nov", "../../secret.py"))]))
PY

Repository: MervinPraison/PraisonAI

Length of output: 5354


Enforce the host file access policy.

execute_file() opens the caller-supplied file_path on the host before the sandbox starts, then uploads its contents to Novita. For untrusted caller input, normalize the path and authorize it against an explicit configured allowlist with a fail-closed default.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 214-214: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(file_path, "r")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/praisonai-sandbox/praisonai_sandbox/novita.py` around lines 214 - 216,
Update execute_file() before the open(file_path, "r") call to normalize the
caller-supplied path and validate it against an explicit configured host-file
allowlist. Reject unauthorized paths, including when no allowlist is configured
(fail closed), before reading or uploading any contents; preserve the existing
authorized-file execution flow.

except OSError as exc:
return SandboxResult(
execution_id=str(uuid.uuid4()),
status=SandboxStatus.FAILED,
error=f"Could not read {file_path}: {exc}",
)

language = "bash" if file_path.endswith((".sh", ".bash")) else "python"
if args:
remote_path = f"/tmp/{uuid.uuid4().hex}_{os.path.basename(file_path)}"
if not await self.write_file(remote_path, code):
return SandboxResult(
execution_id=str(uuid.uuid4()),
status=SandboxStatus.FAILED,
error=f"Could not upload {file_path} to sandbox",
)
interpreter = "bash" if language == "bash" else "python3"
parts = [interpreter, remote_path] + list(args)
return await self.run_command(parts, limits=limits, env=env)

return await self.execute(code, language=language, limits=limits, env=env)

async def write_file(
self,
path: str,
content: Union[str, bytes],
) -> bool:
"""Write a file to the sandbox."""
if not self._is_running:
await self.start()

try:
if isinstance(content, bytes):
content = content.decode("utf-8")
await self._sandbox.files.write(path, content)
return True
except Exception as e:
logger.error(f"Failed to write file {path}: {e}")
return False

async def read_file(
self,
path: str,
) -> Optional[Union[str, bytes]]:
"""Read a file from the sandbox."""
if not self._is_running:
await self.start()

try:
content = await self._sandbox.files.read(path)
return content
except Exception as e:
logger.warning(f"Failed to read file {path}: {e}")
return None

async def list_files(
self,
path: str = "/",
) -> List[str]:
"""List files in a sandbox directory."""
if not self._is_running:
await self.start()

try:
entries = await self._sandbox.files.list(path)
return [entry.path for entry in entries]
except Exception:
return []

def get_status(self) -> Dict[str, Any]:
"""Get sandbox status information."""
return {
"available": self.is_available,
"type": self.sandbox_type,
"running": self._is_running,
"api_key_set": bool(os.getenv("NOVITA_API_KEY")),
}

async def cleanup(self) -> None:
"""Clean up sandbox resources."""
await self.stop()

async def reset(self) -> None:
"""Reset sandbox to initial state."""
await self.stop()
await self.start()
Loading
Loading