Files
OpenJarvis/src/openjarvis/_rust_bridge.py
T
2b14315f3c fix(security): detect Rust at import time + SSRF Python fallback (#467)
* fix(security): detect Rust at import time + SSRF Python fallback (#225)

RUST_AVAILABLE was hardcoded to True, so the exported flag never
reflected reality and the pure-Python fallbacks it was meant to gate
were unreachable.

- _rust_bridge: compute RUST_AVAILABLE dynamically by probing the
  compiled extension once at import time.
- security.ssrf: check_ssrf now falls back to the existing
  _check_ssrf_python implementation when the Rust extension is not
  built, instead of raising ImportError. The SSRF guard is
  security-critical and must never be silently skipped or crash just
  because Rust was not compiled.
- tools.browser: drop the `except ImportError: pass` around the SSRF
  check, which previously disabled SSRF protection entirely on installs
  without the compiled backend (internal/metadata endpoints reachable).
- tests: cover the Python fallback path (metadata IP, private IP, and
  public URL) with RUST_AVAILABLE patched False.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(security): make test_no_hostname backend-agnostic

The cherry-picked #451 fix adds a pure-Python SSRF fallback, but
test_no_hostname asserted the Rust-specific message "Invalid URL". The
Python fallback returns "No hostname in URL" for the same input, so the
SSRF suite failed on exactly the uncompiled-install path #451 targets
(in CI the Rust extension is built, masking it).

Assert the security behavior (URL blocked, non-None reason) and accept
either backend's wording, so the suite passes on both the Rust and
Python paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tools): update browser SSRF test for non-bypassable check

The #225 fix removes the `except ImportError: pass` that silently
disabled the SSRF check in browser navigation. The existing
test_execute_ssrf_module_missing asserted that very anti-pattern ("skip
check and proceed"), so it failed once the swallow was removed.

Replace it with tests that assert the SECURE behavior: the SSRF check
runs unconditionally and is honored (a private-IP URL is blocked even
when navigation would otherwise succeed), and a public URL still
navigates. check_ssrf's pure-Python fallback means the import never
fails anymore, so the old skip path no longer exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Rahul <therahulll56@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:57:44 -07:00

195 lines
6.0 KiB
Python

"""Single point of contact between Python and the Rust ``openjarvis_rust`` module.
Every Python module that wants to delegate to Rust should import helpers from
here rather than importing ``openjarvis_rust`` directly. The Rust backend is
mandatory — if it cannot be imported, a hard ``ImportError`` is raised.
"""
from __future__ import annotations
import functools
import json
from typing import TYPE_CHECKING, List
if TYPE_CHECKING:
import types as _types
# ---------------------------------------------------------------------------
# Mandatory import — Rust backend is required
# ---------------------------------------------------------------------------
@functools.lru_cache(maxsize=1)
def get_rust_module() -> _types.ModuleType:
"""Return the ``openjarvis_rust`` module.
Raises ``ImportError`` if the compiled extension is not available.
The Rust backend is mandatory for all modules that have Rust
implementations — there is no Python fallback.
"""
import openjarvis_rust # type: ignore[import-untyped]
return openjarvis_rust
def _detect_rust() -> bool:
"""Return ``True`` if the compiled ``openjarvis_rust`` extension is importable.
Computed once at import time. Modules with a Python fallback (e.g.
``security.ssrf``) consult this flag instead of hardcoding availability,
so the fallback is actually reachable when the extension was not built.
"""
try:
get_rust_module()
except ImportError:
return False
return True
RUST_AVAILABLE: bool = _detect_rust()
# ---------------------------------------------------------------------------
# JSON -> Python dataclass converters
# ---------------------------------------------------------------------------
def scan_result_from_json(json_str: str) -> object:
"""Convert a Rust scanner JSON string to a Python ``ScanResult``."""
from openjarvis.security.types import (
ScanFinding,
ScanResult,
ThreatLevel,
)
data = json.loads(json_str)
findings: List[ScanFinding] = []
for f in data.get("findings", []):
findings.append(
ScanFinding(
pattern_name=f.get("pattern_name", ""),
matched_text=f.get("matched_text", ""),
threat_level=ThreatLevel(
f.get("threat_level", "low").lower(),
),
start=f.get("start", 0),
end=f.get("end", 0),
description=f.get("description", ""),
)
)
return ScanResult(findings=findings)
def injection_result_from_json(json_str: str) -> object:
"""Convert Rust ``InjectionScanner.scan()`` JSON to dataclass."""
from openjarvis.security.injection_scanner import (
InjectionScanResult,
)
from openjarvis.security.types import ScanFinding, ThreatLevel
data = json.loads(json_str)
findings: List[ScanFinding] = []
for f in data.get("findings", []):
findings.append(
ScanFinding(
pattern_name=f.get("pattern_name", ""),
matched_text=f.get("matched_text", ""),
threat_level=ThreatLevel(
f.get("threat_level", "low").lower(),
),
start=f.get("start", 0),
end=f.get("end", 0),
description=f.get("description", ""),
)
)
threat_raw = data.get("threat_level", "low").lower()
try:
threat = ThreatLevel(threat_raw)
except ValueError:
threat = ThreatLevel.LOW
return InjectionScanResult(
is_clean=data.get("is_clean", True),
findings=findings,
threat_level=threat,
)
def retrieval_results_from_json(json_str: str) -> list:
"""Convert Rust memory ``retrieve()`` JSON to a list of results."""
from openjarvis.tools.storage._stubs import RetrievalResult
items = json.loads(json_str)
results: List[RetrievalResult] = []
for item in items:
meta = item.get("metadata", {})
if isinstance(meta, str):
try:
meta = json.loads(meta)
except (json.JSONDecodeError, TypeError):
meta = {}
results.append(
RetrievalResult(
content=item.get("content", ""),
score=float(item.get("score", 0.0)),
source=item.get("source", ""),
metadata=meta,
)
)
return results
# ---------------------------------------------------------------------------
# Phase 2 converters — optimization & engine types
# ---------------------------------------------------------------------------
def optimization_store_from_rust(path: str = ":memory:") -> object | None:
"""Get a Rust-backed OptimizationStore, or None if Rust unavailable."""
mod = get_rust_module()
if mod is None:
return None
try:
return mod.OptimizationStore(path)
except Exception:
return None
def trial_result_from_json(json_str: str) -> dict:
"""Convert Rust TrialResult JSON to a Python dict."""
return json.loads(json_str)
def optimization_run_from_json(json_str: str) -> dict:
"""Convert Rust OptimizationRun JSON to a Python dict."""
return json.loads(json_str)
def generate_result_from_json(json_str: str) -> dict:
"""Convert Rust GenerateResult JSON to a Python dict."""
data = json.loads(json_str)
return {
"content": data.get("content", ""),
"model": data.get("model", ""),
"finish_reason": data.get("finish_reason", "stop"),
"usage": data.get("usage", {}),
"tool_calls": data.get("tool_calls"),
"ttft": data.get("ttft", 0.0),
"cost_usd": data.get("cost_usd", 0.0),
"metadata": data.get("metadata", {}),
}
__all__ = [
"RUST_AVAILABLE",
"generate_result_from_json",
"get_rust_module",
"injection_result_from_json",
"optimization_run_from_json",
"optimization_store_from_rust",
"retrieval_results_from_json",
"scan_result_from_json",
"trial_result_from_json",
]