mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
Merge pull request #152 from open-jarvis/feat/security-hardening
feat: security hardening — layered boundary enforcement
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,6 +53,7 @@ class InferenceEngine(ABC):
|
||||
"""
|
||||
|
||||
engine_id: str
|
||||
is_cloud: bool = False
|
||||
|
||||
@abstractmethod
|
||||
def generate(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,7 @@ class LiteLLMEngine(InferenceEngine):
|
||||
"""
|
||||
|
||||
engine_id = "litellm"
|
||||
is_cloud = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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=["*"],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -19,6 +19,7 @@ class TestSecurityHeaders:
|
||||
"Strict-Transport-Security",
|
||||
"Referrer-Policy",
|
||||
"Permissions-Policy",
|
||||
"Content-Security-Policy",
|
||||
}
|
||||
assert set(SECURITY_HEADERS.keys()) == expected_keys
|
||||
|
||||
|
||||
Reference in New Issue
Block a user