Merge pull request #152 from open-jarvis/feat/security-hardening

feat: security hardening — layered boundary enforcement
This commit is contained in:
Jon Saad-Falcon
2026-03-28 22:15:22 -07:00
committed by GitHub
37 changed files with 1166 additions and 16 deletions
+27
View File
@@ -217,6 +217,32 @@ def _check_optional_deps() -> List[CheckResult]:
return results
def _check_security_profile() -> CheckResult:
"""Check if a security profile is configured."""
try:
from openjarvis.core.config import load_config
config = load_config()
if config.security.profile:
return CheckResult(
name="Security profile",
status="ok",
message=f"Profile '{config.security.profile}' active",
)
return CheckResult(
name="Security profile",
status="warn",
message="No security profile set",
details="Recommended: add security.profile = 'personal' to config.toml",
)
except Exception as exc:
return CheckResult(
name="Security profile",
status="fail",
message=f"Could not check: {exc}",
)
def _check_nodejs() -> CheckResult:
"""Check Node.js version for Node-backed integrations."""
node_path = shutil.which("node")
@@ -275,6 +301,7 @@ def _run_all_checks() -> List[CheckResult]:
checks.append(_check_default_model())
checks.extend(_check_optional_deps())
checks.append(_check_nodejs())
checks.append(_check_security_profile())
return checks
+19 -3
View File
@@ -7,6 +7,18 @@ from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Optional, Union
from openjarvis.security.credential_stripper import CredentialStripper
_stripper = CredentialStripper()
class SanitizingFormatter(logging.Formatter):
"""Formatter that redacts credentials from log messages."""
def format(self, record: logging.LogRecord) -> str:
msg = super().format(record)
return _stripper.strip(msg)
def setup_logging(
verbose: bool = False,
@@ -47,15 +59,17 @@ def setup_logging(
# Console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
fmt = logging.Formatter("%(levelname)s %(name)s: %(message)s")
fmt = SanitizingFormatter("%(levelname)s %(name)s: %(message)s")
console_handler.setFormatter(fmt)
logger.addHandler(console_handler)
# File handler (verbose or explicit path)
if verbose or log_file is not None:
if log_file is None:
from openjarvis.security.file_utils import secure_mkdir
log_dir = Path.home() / ".openjarvis"
log_dir.mkdir(parents=True, exist_ok=True)
secure_mkdir(log_dir)
log_file = log_dir / "cli.log"
file_handler = RotatingFileHandler(
str(log_file),
@@ -63,7 +77,9 @@ def setup_logging(
backupCount=3,
)
file_handler.setLevel(logging.DEBUG)
file_fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
file_fmt = SanitizingFormatter(
"%(asctime)s %(levelname)s %(name)s: %(message)s"
)
file_handler.setFormatter(file_fmt)
logger.addHandler(file_handler)
+33
View File
@@ -351,6 +351,23 @@ def serve(
except (FileNotFoundError, ImportError):
pass
from openjarvis.server.auth_middleware import check_bind_safety
check_bind_safety(bind_host, api_key=api_key)
# Log credential status at startup
from openjarvis.core.credentials import TOOL_CREDENTIALS, get_credential_status
_cred_parts = []
for _tool_name in sorted(TOOL_CREDENTIALS):
_status = get_credential_status(_tool_name)
_set = sum(1 for v in _status.values() if v)
_total = len(_status)
if _set > 0:
_cred_parts.append(f"{_tool_name}: {_set}/{_total} keys")
if _cred_parts:
logger.info("Credentials loaded — %s", ", ".join(_cred_parts))
webhook_config = {
"twilio_auth_token": _os.environ.get("TWILIO_AUTH_TOKEN", ""),
"bluebubbles_password": _os.environ.get("BLUEBUBBLES_PASSWORD", ""),
@@ -395,6 +412,7 @@ def serve(
agent_scheduler=agent_scheduler,
api_key=api_key,
webhook_config=webhook_config,
cors_origins=config.server.cors_origins,
)
console.print(
@@ -405,6 +423,21 @@ def serve(
f" URL: [cyan]http://{bind_host}:{bind_port}[/cyan]"
)
# Warn about wildcard CORS on non-loopback
import ipaddress as _ipa
try:
_is_loop = _ipa.ip_address(bind_host).is_loopback
except ValueError:
_is_loop = bind_host in ("localhost", "")
if not _is_loop and "*" in config.server.cors_origins:
console.print(
"[yellow bold]WARNING:[/yellow bold] Wildcard CORS with credentials "
"enabled on non-loopback interface. This allows any website to make "
"authenticated requests to your instance."
)
import uvicorn
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
@@ -54,7 +54,9 @@ class AttachmentStore:
base_dir = str(DEFAULT_CONFIG_DIR / "blobs")
self._base_dir = Path(base_dir)
self._base_dir.mkdir(parents=True, exist_ok=True)
from openjarvis.security.file_utils import secure_mkdir
secure_mkdir(self._base_dir)
db_path = self._base_dir / "attachments.db"
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
@@ -95,6 +97,9 @@ class AttachmentStore:
blob_path = blob_dir / sha
if not blob_path.exists():
blob_path.write_bytes(content)
import os
os.chmod(blob_path, 0o600)
# Upsert metadata row
existing = self._conn.execute(
+3 -1
View File
@@ -124,7 +124,9 @@ class KnowledgeStore(MemoryBackend):
self._db_path = str(db_path)
# Ensure the parent directory exists (skip for :memory:)
if self._db_path != ":memory:":
Path(self._db_path).parent.mkdir(parents=True, exist_ok=True)
from openjarvis.security.file_utils import secure_create
secure_create(Path(self._db_path))
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
+109 -3
View File
@@ -29,6 +29,13 @@ DEFAULT_CONFIG_DIR = Path.home() / ".openjarvis"
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
def _ensure_config_dir() -> Path:
"""Ensure the config directory exists with restrictive permissions."""
from openjarvis.security.file_utils import secure_mkdir
return secure_mkdir(DEFAULT_CONFIG_DIR)
@dataclass(slots=True)
class GpuInfo:
"""Detected GPU metadata."""
@@ -760,11 +767,20 @@ class AgentConfig:
class ServerConfig:
"""API server settings."""
host: str = "0.0.0.0"
host: str = "127.0.0.1"
port: int = 8000
agent: str = "orchestrator"
model: str = ""
workers: int = 1
cors_origins: list = field(
default_factory=lambda: [
"http://localhost:3000",
"http://localhost:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173",
"tauri://localhost",
]
)
@dataclass(slots=True)
@@ -972,7 +988,7 @@ class SecurityConfig:
enabled: bool = True
scan_input: bool = True
scan_output: bool = True
mode: str = "warn" # "redact" | "warn" | "block"
mode: str = "redact" # "redact" | "warn" | "block"
secret_scanner: bool = True
pii_scanner: bool = True
audit_log_path: str = str(DEFAULT_CONFIG_DIR / "audit.db")
@@ -980,12 +996,93 @@ class SecurityConfig:
merkle_audit: bool = True
signing_key_path: str = ""
ssrf_protection: bool = True
rate_limit_enabled: bool = False
rate_limit_enabled: bool = True
rate_limit_rpm: int = 60
rate_limit_burst: int = 10
local_engine_bypass: bool = False
local_tool_bypass: bool = False
profile: str = ""
vault_key_path: str = str(DEFAULT_CONFIG_DIR / ".vault_key")
capabilities: CapabilitiesConfig = field(default_factory=CapabilitiesConfig)
# ---------------------------------------------------------------------------
# Security profile presets
# ---------------------------------------------------------------------------
_SECURITY_PROFILES: Dict[str, Dict[str, Dict[str, Any]]] = {
"personal": {
"security": {
"mode": "redact",
"rate_limit_enabled": True,
"local_engine_bypass": False,
"local_tool_bypass": False,
},
"server": {
"host": "127.0.0.1",
},
},
"shared": {
"security": {
"mode": "redact",
"rate_limit_enabled": True,
"local_engine_bypass": False,
"local_tool_bypass": False,
},
"server": {
"host": "127.0.0.1",
},
},
"server": {
"security": {
"mode": "block",
"rate_limit_enabled": True,
"rate_limit_rpm": 30,
"rate_limit_burst": 5,
"local_engine_bypass": False,
"local_tool_bypass": False,
},
"server": {
"host": "0.0.0.0",
},
},
}
def apply_security_profile(
security_cfg: "SecurityConfig",
server_cfg: "ServerConfig | None",
*,
overrides: "set[str] | None" = None,
) -> None:
"""Expand a named security profile into config fields.
Fields in *overrides* (explicitly set by the user in TOML) are
not overwritten by the profile.
"""
profile = security_cfg.profile
if not profile:
return
if profile not in _SECURITY_PROFILES:
raise ValueError(
f"Unknown security profile '{profile}'. "
f"Valid profiles: {', '.join(_SECURITY_PROFILES)}"
)
_overrides = overrides or set()
pdef = _SECURITY_PROFILES[profile]
for key, value in pdef.get("security", {}).items():
if key not in _overrides and hasattr(security_cfg, key):
setattr(security_cfg, key, value)
if server_cfg is not None:
for key, value in pdef.get("server", {}).items():
if key not in _overrides and hasattr(server_cfg, key):
setattr(server_cfg, key, value)
@dataclass(slots=True)
class SandboxConfig:
"""Container sandbox settings."""
@@ -1314,6 +1411,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
Explicit config file. If not set, uses ``OPENJARVIS_CONFIG`` when set,
otherwise ``~/.openjarvis/config.toml``.
"""
_ensure_config_dir()
hw = detect_hardware()
cfg = JarvisConfig(hardware=hw)
cfg.engine.default = recommend_engine(hw)
@@ -1365,6 +1463,14 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
if "memory" in data:
_apply_toml_section(cfg.tools.storage, data["memory"])
# Expand security profile (user TOML overrides take precedence)
_user_security_keys = set(data.get("security", {}).keys())
apply_security_profile(cfg.security, cfg.server, overrides=_user_security_keys)
# Apply profile even without a config file (in case defaults set one)
if not config_path.exists() and cfg.security.profile:
apply_security_profile(cfg.security, cfg.server)
return cfg
+19
View File
@@ -108,3 +108,22 @@ def inject_credentials(path: Path | None = None) -> None:
for k, v in kvs.items():
if k not in os.environ:
os.environ[k] = v
def get_tool_credential(
tool_name: str,
key: str,
*,
path: Path | None = None,
) -> str | None:
"""Read a single credential without polluting ``os.environ``.
Falls back to ``os.environ`` if the key is not in credentials.toml,
for backward compatibility with Docker env var workflows.
"""
creds = load_credentials(path=path)
tool_creds = creds.get(tool_name, {})
value = tool_creds.get(key)
if value is not None:
return value
return os.environ.get(key) or None
+1
View File
@@ -53,6 +53,7 @@ class InferenceEngine(ABC):
"""
engine_id: str
is_cloud: bool = False
@abstractmethod
def generate(
+1
View File
@@ -213,6 +213,7 @@ class CloudEngine(InferenceEngine):
"""Cloud inference via OpenAI, Anthropic, Google, and MiniMax SDKs."""
engine_id = "cloud"
is_cloud = True
def __init__(self) -> None:
self._openai_client: Any = None
+1
View File
@@ -27,6 +27,7 @@ class LiteLLMEngine(InferenceEngine):
"""
engine_id = "litellm"
is_cloud = True
def __init__(
self,
+3 -1
View File
@@ -36,7 +36,9 @@ class AuditLogger:
bus: Optional[EventBus] = None,
) -> None:
self._db_path = Path(db_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
from openjarvis.security.file_utils import secure_create
secure_create(self._db_path)
self._conn = sqlite3.connect(str(self._db_path))
self._conn.execute(
"""
+139
View File
@@ -0,0 +1,139 @@
"""BoundaryGuard — scans content at device exit points.
Wraps SecretScanner and PIIScanner to redact, warn, or block
secrets and PII before data leaves the device via cloud engines
or external tool calls.
"""
from __future__ import annotations
import logging
from dataclasses import replace
from typing import TYPE_CHECKING, List, Optional
from openjarvis.core.types import ToolCall
if TYPE_CHECKING:
from openjarvis.core.events import EventBus
from openjarvis.security._stubs import BaseScanner
logger = logging.getLogger(__name__)
class SecurityBlockError(Exception):
"""Raised when mode='block' and secrets/PII are detected."""
class BoundaryGuard:
"""Scans outbound content for secrets and PII at device boundaries.
Parameters
----------
mode:
Action on findings: ``"redact"`` replaces matches,
``"warn"`` logs but passes through, ``"block"`` raises.
enabled:
Master switch. When ``False``, all content passes through.
bus:
Optional event bus for publishing SECURITY_ALERT events.
scanners:
Custom scanners. Defaults to SecretScanner + PIIScanner.
"""
def __init__(
self,
mode: str = "redact",
*,
enabled: bool = True,
bus: Optional["EventBus"] = None,
scanners: Optional[List["BaseScanner"]] = None,
) -> None:
self._mode = mode
self._enabled = enabled
self._bus = bus
if scanners is not None:
self._scanners = scanners
else:
self._scanners = self._default_scanners()
@staticmethod
def _default_scanners() -> List["BaseScanner"]:
try:
from openjarvis.security.scanner import PIIScanner, SecretScanner
return [SecretScanner(), PIIScanner()]
except (ImportError, Exception) as exc:
logger.warning(
"Rust-backed scanners unavailable (%s); "
"BoundaryGuard running without scanners. "
"Build the Rust extension: uv run maturin develop",
exc,
)
return []
def scan_outbound(self, content: str, destination: str) -> str:
"""Scan text before it leaves the device.
Returns redacted text in ``"redact"`` mode, original text in
``"warn"`` mode, or raises ``SecurityBlockError`` in ``"block"``
mode when findings are detected.
"""
if not self._enabled or not content:
return content
has_findings = False
redacted = content
for scanner in self._scanners:
result = scanner.scan(content)
if result.findings:
has_findings = True
if self._mode == "redact":
redacted = scanner.redact(redacted)
if has_findings:
self._emit_alert(destination, content)
if self._mode == "block":
raise SecurityBlockError(
f"Secrets/PII detected in outbound content to {destination}"
)
if self._mode == "warn":
logger.warning(
"Secrets/PII detected in outbound content to %s", destination
)
return content
return redacted
return content
def check_outbound(self, tool_call: ToolCall) -> ToolCall:
"""Scan tool call arguments before execution.
Returns a new ToolCall with redacted arguments if needed.
"""
if not self._enabled or not tool_call.arguments:
return tool_call
redacted_args = self.scan_outbound(
tool_call.arguments, destination=f"tool:{tool_call.name}"
)
if redacted_args != tool_call.arguments:
return replace(tool_call, arguments=redacted_args)
return tool_call
def _emit_alert(self, destination: str, content: str) -> None:
if self._bus is None:
return
try:
from openjarvis.core.events import EventType
self._bus.publish(
EventType.SECURITY_ALERT,
{
"source": "boundary_guard",
"destination": destination,
"mode": self._mode,
"content_preview": content[:80],
},
)
except Exception:
logger.debug("Failed to emit security alert event", exc_info=True)
+34
View File
@@ -0,0 +1,34 @@
"""Secure file and directory creation helpers.
All OpenJarvis data files under ``~/.openjarvis/`` should be created
through these helpers to ensure consistent, restrictive permissions.
"""
from __future__ import annotations
import os
from pathlib import Path
def secure_mkdir(path: Path, mode: int = 0o700) -> Path:
"""Create a directory with restrictive permissions.
Creates parent directories as needed, then sets *mode* on the
target directory (even if it already exists).
"""
path.mkdir(parents=True, exist_ok=True)
os.chmod(path, mode)
return path
def secure_create(path: Path, mode: int = 0o600) -> Path:
"""Ensure a file exists with restrictive permissions.
Creates the parent directory with ``0o700`` if needed, touches the
file if it doesn't exist, and sets *mode* on it.
"""
secure_mkdir(path.parent, mode=0o700)
if not path.exists():
path.touch()
os.chmod(path, mode)
return path
+3 -1
View File
@@ -153,6 +153,7 @@ def create_app(
agent_scheduler=None,
api_key: str = "",
webhook_config: dict | None = None,
cors_origins: list[str] | None = None,
) -> FastAPI:
"""Create and configure the FastAPI application.
@@ -179,9 +180,10 @@ def create_app(
from fastapi.middleware.cors import CORSMiddleware
_origins = cors_origins if cors_origins is not None else ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
+23
View File
@@ -56,3 +56,26 @@ class AuthMiddleware(BaseHTTPMiddleware):
def generate_api_key() -> str:
"""Generate a new API key with ``oj_sk_`` prefix."""
return f"oj_sk_{secrets.token_urlsafe(32)}"
def check_bind_safety(host: str, *, api_key: str) -> None:
"""Refuse to bind non-loopback without an API key.
Raises ``SystemExit`` if *host* is not a loopback address and
*api_key* is empty.
"""
import ipaddress
import sys
try:
is_loop = ipaddress.ip_address(host).is_loopback
except ValueError:
is_loop = host in ("localhost", "")
if not is_loop and not api_key:
logger.error(
"Binding to %s requires OPENJARVIS_API_KEY to be set. "
"Run: jarvis auth generate-key",
host,
)
sys.exit(1)
+2
View File
@@ -48,6 +48,7 @@ def create_security_middleware() -> Any:
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=()"
)
response.headers["Content-Security-Policy"] = "default-src 'self'"
return response
return SecurityHeadersMiddleware
@@ -61,4 +62,5 @@ SECURITY_HEADERS = {
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Content-Security-Policy": "default-src 'self'",
}
+3 -1
View File
@@ -23,7 +23,9 @@ class SessionStore:
def __init__(self, db_path: str = "") -> None:
if not db_path:
db_path = str(Path.home() / ".openjarvis" / "sessions.db")
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
from openjarvis.security.file_utils import secure_create
secure_create(Path(db_path))
self._db = sqlite3.connect(db_path, check_same_thread=False)
self._db.row_factory = sqlite3.Row
self._create_tables()
+18 -3
View File
@@ -34,15 +34,25 @@ def _validate_twilio_signature(
params: dict,
signature: str,
) -> bool:
"""Validate Twilio webhook signature using the SDK."""
"""Validate Twilio webhook signature using the SDK.
Fails closed: returns ``False`` if the SDK is not installed or
if no auth_token is configured.
"""
if not auth_token:
logger.error("Twilio auth token not configured — rejecting webhook")
return False
try:
from twilio.request_validator import RequestValidator
validator = RequestValidator(auth_token)
return validator.validate(url, params, signature)
except ImportError:
logger.warning("twilio SDK not installed — skipping signature validation")
return True
logger.error(
"twilio SDK not installed — rejecting webhook. "
"Install it: pip install twilio"
)
return False
def _format_for_sms(text: str) -> str:
@@ -335,6 +345,11 @@ def create_webhook_router(
header_secret = request.headers.get("x-sendblue-secret", "")
if header_secret != sb.webhook_secret:
return Response("Invalid secret", status_code=403)
elif sb:
logger.warning(
"SendBlue webhook received without secret verification. "
"Set webhook_secret for HMAC validation."
)
# Ignore outbound status callbacks
if payload.get("is_outbound", False):
+1
View File
@@ -43,6 +43,7 @@ class JarvisSystem:
session_store: Optional[Any] = None # SessionStore
capability_policy: Optional[Any] = None # CapabilityPolicy
audit_logger: Optional[Any] = None # AuditLogger
boundary_guard: Optional[Any] = None # BoundaryGuard
operator_manager: Optional[Any] = None # OperatorManager
agent_manager: Optional[Any] = None # AgentManager
agent_scheduler: Optional[Any] = None # AgentScheduler
+19
View File
@@ -51,6 +51,7 @@ class BaseTool(ABC):
"""
tool_id: str
is_local: bool = True
@property
@abstractmethod
@@ -100,6 +101,7 @@ class ToolExecutor:
default_timeout: float = 30.0,
capability_policy: Optional[Any] = None,
agent_id: str = "",
boundary_guard: Optional[Any] = None,
) -> None:
self._tools: Dict[str, BaseTool] = {t.spec.name: t for t in tools}
self._bus = bus
@@ -108,6 +110,7 @@ class ToolExecutor:
self._default_timeout = default_timeout
self._capability_policy = capability_policy
self._agent_id = agent_id
self._boundary_guard = boundary_guard
def execute(self, tool_call: ToolCall) -> ToolResult:
"""Parse arguments, dispatch to tool, measure latency, emit events."""
@@ -129,6 +132,22 @@ class ToolExecutor:
success=False,
)
# Boundary guard: scan external tool arguments
if (
self._boundary_guard is not None
and not getattr(tool, "is_local", True)
):
try:
tool_call = self._boundary_guard.check_outbound(tool_call)
# Re-parse arguments after potential redaction
params = json.loads(tool_call.arguments) if tool_call.arguments else {}
except Exception as exc:
return ToolResult(
tool_name=tool_call.name,
content=f"Security block: {exc}",
success=False,
)
# RBAC capability check
if self._capability_policy and tool.spec.required_capabilities:
for cap in tool.spec.required_capabilities:
+1
View File
@@ -19,6 +19,7 @@ class AudioTranscribeTool(BaseTool):
"""Transcribe audio files using OpenAI Whisper or a local provider."""
tool_id = "audio_transcribe"
is_local = False
@property
def spec(self) -> ToolSpec:
+5
View File
@@ -57,6 +57,7 @@ class BrowserNavigateTool(BaseTool):
"""Navigate to a URL in the browser."""
tool_id = "browser_navigate"
is_local = False
@property
def spec(self) -> ToolSpec:
@@ -155,6 +156,7 @@ class BrowserClickTool(BaseTool):
"""Click an element on the page."""
tool_id = "browser_click"
is_local = False
@property
def spec(self) -> ToolSpec:
@@ -234,6 +236,7 @@ class BrowserTypeTool(BaseTool):
"""Type text into a form field."""
tool_id = "browser_type"
is_local = False
@property
def spec(self) -> ToolSpec:
@@ -324,6 +327,7 @@ class BrowserScreenshotTool(BaseTool):
"""Take a screenshot of the current page."""
tool_id = "browser_screenshot"
is_local = False
@property
def spec(self) -> ToolSpec:
@@ -403,6 +407,7 @@ class BrowserExtractTool(BaseTool):
"""Extract content from the current page."""
tool_id = "browser_extract"
is_local = False
@property
def spec(self) -> ToolSpec:
+1
View File
@@ -28,6 +28,7 @@ class BrowserAXTreeTool(BaseTool):
"""Extract the accessibility tree from the current browser page."""
tool_id = "browser_axtree"
is_local = False
@property
def spec(self) -> ToolSpec:
+1
View File
@@ -19,6 +19,7 @@ class ChannelSendTool(BaseTool):
"""MCP-exposed tool: send a message via a channel backend."""
tool_id = "channel_send"
is_local = False
def __init__(self, channel: BaseChannel | None = None) -> None:
self._channel = channel
+1
View File
@@ -26,6 +26,7 @@ class HttpRequestTool(BaseTool):
"""Make HTTP requests to external APIs with SSRF protection."""
tool_id = "http_request"
is_local = False
@property
def spec(self) -> ToolSpec:
+1
View File
@@ -17,6 +17,7 @@ class ImageGenerateTool(BaseTool):
"""Generate images from text descriptions via OpenAI DALL-E."""
tool_id = "image_generate"
is_local = False
@property
def spec(self) -> ToolSpec:
+1
View File
@@ -19,6 +19,7 @@ class WebSearchTool(BaseTool):
"""Search the web via Tavily API."""
tool_id = "web_search"
is_local = False
def __init__(self, api_key: str | None = None, max_results: int = 5):
self._api_key = api_key or os.environ.get("TAVILY_API_KEY")
+4
View File
@@ -81,6 +81,10 @@ class TraceStore:
def __init__(self, db_path: str | Path) -> None:
self._db_path = str(db_path)
if self._db_path != ":memory:":
from openjarvis.security.file_utils import secure_create
secure_create(Path(self._db_path))
# check_same_thread=False is safe with WAL mode. The
# AgenticRunner dispatches agent work to a ThreadPoolExecutor
# (for Playwright compat), so trace writes may originate from
+1 -1
View File
@@ -115,7 +115,7 @@ class TestSecurityConfig:
assert sc.enabled is True
assert sc.scan_input is True
assert sc.scan_output is True
assert sc.mode == "warn"
assert sc.mode == "redact"
assert sc.secret_scanner is True
assert sc.pii_scanner is True
assert sc.enforce_tool_confirmation is True
+1 -1
View File
@@ -37,7 +37,7 @@ class TestAgentConfig:
class TestServerConfig:
def test_defaults(self):
cfg = ServerConfig()
assert cfg.host == "0.0.0.0"
assert cfg.host == "127.0.0.1"
assert cfg.port == 8000
assert cfg.agent == "orchestrator"
assert cfg.model == ""
+265
View File
@@ -0,0 +1,265 @@
"""Tests for BoundaryGuard — scanning at device exit points."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import List
import pytest
from openjarvis.core.types import ToolCall
# ---------------------------------------------------------------------------
# Lightweight mock scanners that don't depend on Rust
# ---------------------------------------------------------------------------
@dataclass
class _Finding:
pattern_name: str
matched_text: str
threat_level: str = "CRITICAL"
@dataclass
class _ScanResult:
findings: List[_Finding] = field(default_factory=list)
class _MockSecretScanner:
"""Regex-only secret scanner for testing without Rust."""
_PATTERNS = [
("openai_key", re.compile(r"sk-[A-Za-z0-9_-]{20,}")),
("aws_key", re.compile(r"AKIA[0-9A-Z]{16}")),
("slack_token", re.compile(r"xoxb-[0-9A-Za-z\-]+")),
]
def scan(self, text: str) -> _ScanResult:
findings = []
for name, pattern in self._PATTERNS:
for m in pattern.finditer(text):
findings.append(_Finding(pattern_name=name, matched_text=m.group()))
return _ScanResult(findings=findings)
def redact(self, text: str) -> str:
for name, pattern in self._PATTERNS:
text = pattern.sub(f"[REDACTED:{name}]", text)
return text
def _make_guard(mode: str = "redact", enabled: bool = True):
"""Create a BoundaryGuard with mock scanners (no Rust needed)."""
from openjarvis.security.boundary import BoundaryGuard
return BoundaryGuard(
mode=mode,
enabled=enabled,
scanners=[_MockSecretScanner()],
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestBoundaryGuardScanOutbound:
"""scan_outbound should detect and redact secrets/PII."""
def test_redacts_openai_key(self) -> None:
guard = _make_guard(mode="redact")
text = "Use this key: sk-proj-abc123def456ghi789jkl012mno345pqr678stu"
result = guard.scan_outbound(text, destination="openai")
assert "sk-proj-" not in result
assert "[REDACTED" in result
def test_redacts_aws_key(self) -> None:
guard = _make_guard(mode="redact")
text = "AWS key: AKIAIOSFODNN7EXAMPLE"
result = guard.scan_outbound(text, destination="openai")
assert "AKIAIOSFODNN7EXAMPLE" not in result
def test_warn_mode_does_not_alter_text(self) -> None:
guard = _make_guard(mode="warn")
text = "Use this key: sk-proj-abc123def456ghi789jkl012mno345pqr678stu"
result = guard.scan_outbound(text, destination="openai")
assert result == text
def test_block_mode_raises(self) -> None:
from openjarvis.security.boundary import SecurityBlockError
guard = _make_guard(mode="block")
text = "Use this key: sk-proj-abc123def456ghi789jkl012mno345pqr678stu"
with pytest.raises(SecurityBlockError):
guard.scan_outbound(text, destination="openai")
def test_clean_text_passes_through(self) -> None:
guard = _make_guard(mode="redact")
text = "Hello, how are you?"
result = guard.scan_outbound(text, destination="openai")
assert result == text
class TestBoundaryGuardCheckOutbound:
"""check_outbound should redact secrets in tool call arguments."""
def test_redacts_tool_call_arguments(self) -> None:
guard = _make_guard(mode="redact")
tc = ToolCall(
id="test_1",
name="web_search",
arguments=(
'{"query": "my key is sk-proj-abc123def456ghi789jkl012mno345pqr678stu"}'
),
)
result = guard.check_outbound(tc)
assert "sk-proj-" not in result.arguments
assert result.id == "test_1"
assert result.name == "web_search"
def test_clean_args_pass_through(self) -> None:
guard = _make_guard(mode="redact")
tc = ToolCall(id="test_2", name="web_search", arguments='{"query": "weather"}')
result = guard.check_outbound(tc)
assert result.arguments == tc.arguments
def test_block_mode_raises_on_tool_call(self) -> None:
from openjarvis.security.boundary import SecurityBlockError
guard = _make_guard(mode="block")
tc = ToolCall(
id="test_3",
name="web_search",
arguments='{"query": "AKIAIOSFODNN7EXAMPLE"}',
)
with pytest.raises(SecurityBlockError):
guard.check_outbound(tc)
class TestBoundaryGuardDisabled:
"""When disabled, BoundaryGuard should pass everything through."""
def test_disabled_passes_secrets_through(self) -> None:
guard = _make_guard(mode="redact", enabled=False)
text = "sk-proj-abc123def456ghi789jkl012mno345pqr678stu"
result = guard.scan_outbound(text, destination="openai")
assert result == text
class TestEngineTagging:
"""Cloud engines must have is_cloud=True, local engines is_cloud=False."""
def test_inference_engine_default_is_local(self) -> None:
from openjarvis.engine._stubs import InferenceEngine
assert InferenceEngine.is_cloud is False
def test_cloud_engine_is_cloud(self) -> None:
from openjarvis.engine.cloud import CloudEngine
assert CloudEngine.is_cloud is True
def test_litellm_engine_is_cloud(self) -> None:
from openjarvis.engine.litellm import LiteLLMEngine
assert LiteLLMEngine.is_cloud is True
def test_ollama_engine_is_local(self) -> None:
from openjarvis.engine.ollama import OllamaEngine
assert OllamaEngine.is_cloud is False
class TestToolTagging:
"""External tools must have is_local=False, local tools is_local=True."""
def test_base_tool_default_is_local(self) -> None:
from openjarvis.tools._stubs import BaseTool
assert BaseTool.is_local is True
def test_web_search_is_external(self) -> None:
from openjarvis.tools.web_search import WebSearchTool
assert WebSearchTool.is_local is False
def test_http_request_is_external(self) -> None:
from openjarvis.tools.http_request import HttpRequestTool
assert HttpRequestTool.is_local is False
def test_channel_send_is_external(self) -> None:
from openjarvis.tools.channel_tools import ChannelSendTool
assert ChannelSendTool.is_local is False
def test_think_tool_is_local(self) -> None:
from openjarvis.tools.think import ThinkTool
assert ThinkTool.is_local is True
def test_calculator_is_local(self) -> None:
from openjarvis.tools.calculator import CalculatorTool
assert CalculatorTool.is_local is True
class TestToolExecutorBoundaryIntegration:
"""ToolExecutor should use BoundaryGuard for external tool calls."""
def _make_executor(self, boundary_guard=None):
from openjarvis.tools._stubs import BaseTool, ToolExecutor, ToolSpec
class FakeExternalTool(BaseTool):
tool_id = "fake_external"
is_local = False
@property
def spec(self):
return ToolSpec(
name="fake_external",
description="test",
parameters={
"type": "object",
"properties": {"q": {"type": "string"}},
},
)
def execute(self, **params):
from openjarvis.core.types import ToolResult
return ToolResult(
tool_name="fake_external",
content=f"result for {params.get('q', '')}",
success=True,
)
return ToolExecutor(
tools=[FakeExternalTool()],
boundary_guard=boundary_guard,
)
def test_external_tool_args_scanned(self) -> None:
guard = _make_guard(mode="redact")
executor = self._make_executor(boundary_guard=guard)
tc = ToolCall(
id="t1",
name="fake_external",
arguments=(
'{"q": "my key is sk-proj-abc123def456ghi789jkl012mno345pqr678stu"}'
),
)
result = executor.execute(tc)
assert "sk-proj-" not in result.content
def test_no_guard_passes_through(self) -> None:
executor = self._make_executor(boundary_guard=None)
tc = ToolCall(
id="t2",
name="fake_external",
arguments='{"q": "sk-proj-abc123def456ghi789jkl012mno345pqr678stu"}',
)
result = executor.execute(tc)
assert "sk-proj-" in result.content
+74
View File
@@ -0,0 +1,74 @@
"""Tests for secure file creation helpers (Section 4)."""
from __future__ import annotations
import os
import stat
import tempfile
from pathlib import Path
class TestSecureMkdir:
"""secure_mkdir should create directories with 0o700."""
def test_creates_directory_with_700(self) -> None:
from openjarvis.security.file_utils import secure_mkdir
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "secure_dir"
result = secure_mkdir(target)
assert result.is_dir()
mode = stat.S_IMODE(os.stat(target).st_mode)
assert mode == 0o700
def test_creates_parent_directories(self) -> None:
from openjarvis.security.file_utils import secure_mkdir
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "a" / "b" / "c"
result = secure_mkdir(target)
assert result.is_dir()
def test_existing_directory_gets_permission_fix(self) -> None:
from openjarvis.security.file_utils import secure_mkdir
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "existing"
target.mkdir(mode=0o755)
secure_mkdir(target)
mode = stat.S_IMODE(os.stat(target).st_mode)
assert mode == 0o700
class TestSecureCreate:
"""secure_create should create files with 0o600."""
def test_creates_file_with_600(self) -> None:
from openjarvis.security.file_utils import secure_create
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "secure_file.db"
result = secure_create(target)
assert result.exists()
mode = stat.S_IMODE(os.stat(target).st_mode)
assert mode == 0o600
def test_existing_file_gets_permission_fix(self) -> None:
from openjarvis.security.file_utils import secure_create
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "existing.db"
target.write_text("data")
os.chmod(target, 0o644)
secure_create(target)
mode = stat.S_IMODE(os.stat(target).st_mode)
assert mode == 0o600
def test_creates_parent_directory_with_700(self) -> None:
from openjarvis.security.file_utils import secure_create
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "sub" / "file.db"
secure_create(target)
parent_mode = stat.S_IMODE(os.stat(target.parent).st_mode)
assert parent_mode == 0o700
+114
View File
@@ -0,0 +1,114 @@
"""Tests for log sanitization (Section 5)."""
from __future__ import annotations
import logging
import os
import tempfile
from pathlib import Path
class TestSanitizingFormatter:
"""SanitizingFormatter should redact secrets in log messages."""
def test_redacts_openai_key(self) -> None:
from openjarvis.cli.log_config import SanitizingFormatter
fmt = SanitizingFormatter("%(message)s")
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="Key is sk-proj-abc123def456ghi789jkl012mno345pqr678stu",
args=(),
exc_info=None,
)
result = fmt.format(record)
assert "sk-proj-" not in result
assert "[REDACTED" in result
def test_redacts_aws_key(self) -> None:
from openjarvis.cli.log_config import SanitizingFormatter
fmt = SanitizingFormatter("%(message)s")
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="AWS: AKIAIOSFODNN7EXAMPLE",
args=(),
exc_info=None,
)
result = fmt.format(record)
assert "AKIAIOSFODNN7EXAMPLE" not in result
def test_clean_message_unchanged(self) -> None:
from openjarvis.cli.log_config import SanitizingFormatter
fmt = SanitizingFormatter("%(message)s")
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="Server started on port 8000",
args=(),
exc_info=None,
)
result = fmt.format(record)
assert result == "Server started on port 8000"
def test_redacts_slack_token(self) -> None:
from openjarvis.cli.log_config import SanitizingFormatter
fmt = SanitizingFormatter("%(message)s")
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname="",
lineno=0,
msg="Token: xoxb-1234-5678-abcdefghij",
args=(),
exc_info=None,
)
result = fmt.format(record)
assert "xoxb-" not in result
class TestScopedCredentialAccess:
"""get_tool_credential should return values without polluting os.environ."""
def test_returns_credential_value(self) -> None:
from openjarvis.core.credentials import get_tool_credential
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write('[slack]\nSLACK_BOT_TOKEN = "xoxb-test-token"\n')
f.flush()
result = get_tool_credential(
"slack", "SLACK_BOT_TOKEN", path=Path(f.name)
)
assert result == "xoxb-test-token"
assert os.environ.get("SLACK_BOT_TOKEN") != "xoxb-test-token"
os.unlink(f.name)
def test_returns_none_for_missing(self) -> None:
from openjarvis.core.credentials import get_tool_credential
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("[slack]\n")
f.flush()
result = get_tool_credential(
"slack", "SLACK_BOT_TOKEN", path=Path(f.name)
)
assert result is None
os.unlink(f.name)
def test_returns_none_for_missing_file(self) -> None:
from openjarvis.core.credentials import get_tool_credential
result = get_tool_credential(
"slack", "SLACK_BOT_TOKEN", path=Path("/nonexistent/file.toml")
)
assert result is None
+139
View File
@@ -0,0 +1,139 @@
"""Tests for secure network defaults (Section 1 of security hardening)."""
from __future__ import annotations
import ipaddress
import pytest
class TestServerConfigDefaults:
"""ServerConfig should bind to loopback by default."""
def test_default_host_is_loopback(self) -> None:
from openjarvis.core.config import ServerConfig
cfg = ServerConfig()
assert cfg.host == "127.0.0.1"
def test_default_port_unchanged(self) -> None:
from openjarvis.core.config import ServerConfig
cfg = ServerConfig()
assert cfg.port == 8000
def test_cors_origins_default(self) -> None:
from openjarvis.core.config import ServerConfig
cfg = ServerConfig()
assert isinstance(cfg.cors_origins, list)
assert "http://localhost:3000" in cfg.cors_origins
assert "http://localhost:5173" in cfg.cors_origins
assert "tauri://localhost" in cfg.cors_origins
assert "*" not in cfg.cors_origins
class TestSecurityConfigDefaults:
"""SecurityConfig should default to redact mode with rate limiting."""
def test_default_mode_is_redact(self) -> None:
from openjarvis.core.config import SecurityConfig
cfg = SecurityConfig()
assert cfg.mode == "redact"
def test_rate_limiting_enabled_by_default(self) -> None:
from openjarvis.core.config import SecurityConfig
cfg = SecurityConfig()
assert cfg.rate_limit_enabled is True
def test_bypass_defaults_conservative(self) -> None:
from openjarvis.core.config import SecurityConfig
cfg = SecurityConfig()
assert cfg.local_engine_bypass is False
assert cfg.local_tool_bypass is False
def test_profile_default_empty(self) -> None:
from openjarvis.core.config import SecurityConfig
cfg = SecurityConfig()
assert cfg.profile == ""
def _is_loopback(host: str) -> bool:
"""Check if a host string is a loopback address."""
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return host in ("localhost", "")
class TestNonLoopbackAuthEnforcement:
"""Server must require API key when binding non-loopback."""
def test_loopback_allows_no_key(self) -> None:
assert _is_loopback("127.0.0.1")
def test_wildcard_is_not_loopback(self) -> None:
assert not _is_loopback("0.0.0.0")
def test_non_loopback_requires_key(self) -> None:
starlette = pytest.importorskip("starlette") # noqa: F841
from openjarvis.server.auth_middleware import check_bind_safety
try:
check_bind_safety("0.0.0.0", api_key="")
assert False, "Should have raised"
except SystemExit:
pass
def test_non_loopback_with_key_ok(self) -> None:
starlette = pytest.importorskip("starlette") # noqa: F841
from openjarvis.server.auth_middleware import check_bind_safety
check_bind_safety("0.0.0.0", api_key="oj_sk_test123")
class TestCORSConfiguration:
"""CORS should use configured origins, not wildcard."""
def test_create_app_uses_configured_origins(self) -> None:
pytest.importorskip("fastapi")
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from openjarvis.server.app import create_app
mock_engine = MagicMock()
mock_engine.health.return_value = True
mock_engine.list_models.return_value = ["test-model"]
app = create_app(
mock_engine,
"test-model",
cors_origins=["http://localhost:3000"],
)
client = TestClient(app)
resp = client.options(
"/health",
headers={
"Origin": "http://localhost:3000",
"Access-Control-Request-Method": "GET",
},
)
assert (
resp.headers.get("access-control-allow-origin") == "http://localhost:3000"
)
resp2 = client.options(
"/health",
headers={
"Origin": "http://evil.com",
"Access-Control-Request-Method": "GET",
},
)
assert resp2.headers.get("access-control-allow-origin") != "http://evil.com"
+54
View File
@@ -0,0 +1,54 @@
"""Tests for security profile expansion (Section 7)."""
from __future__ import annotations
import pytest
class TestProfileExpansion:
"""Profiles should pre-fill security and server config fields."""
def test_personal_profile_sets_redact(self) -> None:
from openjarvis.core.config import SecurityConfig, apply_security_profile
cfg = SecurityConfig(profile="personal")
apply_security_profile(cfg, None)
assert cfg.mode == "redact"
assert cfg.rate_limit_enabled is True
def test_server_profile_sets_block(self) -> None:
from openjarvis.core.config import (
SecurityConfig,
ServerConfig,
apply_security_profile,
)
cfg = SecurityConfig(profile="server")
server_cfg = ServerConfig()
apply_security_profile(cfg, server_cfg)
assert cfg.mode == "block"
assert cfg.rate_limit_rpm == 30
assert cfg.rate_limit_burst == 5
assert server_cfg.host == "0.0.0.0"
def test_explicit_override_beats_profile(self) -> None:
from openjarvis.core.config import SecurityConfig, apply_security_profile
cfg = SecurityConfig(profile="server", mode="warn")
apply_security_profile(cfg, None, overrides={"mode"})
assert cfg.mode == "warn"
def test_empty_profile_is_noop(self) -> None:
from openjarvis.core.config import SecurityConfig, apply_security_profile
cfg = SecurityConfig()
original_mode = cfg.mode
apply_security_profile(cfg, None)
assert cfg.mode == original_mode
def test_unknown_profile_raises(self) -> None:
from openjarvis.core.config import SecurityConfig, apply_security_profile
cfg = SecurityConfig(profile="nonexistent")
with pytest.raises(ValueError, match="Unknown security profile"):
apply_security_profile(cfg, None)
+38
View File
@@ -0,0 +1,38 @@
"""Tests for webhook fail-closed validation (Section 3)."""
from __future__ import annotations
from unittest.mock import patch
import pytest
class TestTwilioValidationFailClosed:
"""Twilio validation must reject when SDK is unavailable."""
def test_missing_sdk_returns_false(self) -> None:
pytest.importorskip("fastapi")
from openjarvis.server.webhook_routes import _validate_twilio_signature
with patch.dict(
"sys.modules", {"twilio": None, "twilio.request_validator": None}
):
result = _validate_twilio_signature(
auth_token="test_token",
url="https://example.com/webhooks/twilio",
params={"Body": "hello"},
signature="invalid",
)
assert result is False
def test_empty_auth_token_returns_false(self) -> None:
pytest.importorskip("fastapi")
from openjarvis.server.webhook_routes import _validate_twilio_signature
result = _validate_twilio_signature(
auth_token="",
url="https://example.com/webhooks/twilio",
params={},
signature="",
)
assert result is False
+1
View File
@@ -19,6 +19,7 @@ class TestSecurityHeaders:
"Strict-Transport-Security",
"Referrer-Policy",
"Permissions-Policy",
"Content-Security-Policy",
}
assert set(SECURITY_HEADERS.keys()) == expected_keys