diff --git a/src/openjarvis/cli/doctor_cmd.py b/src/openjarvis/cli/doctor_cmd.py index dbfe2280..15a66f33 100644 --- a/src/openjarvis/cli/doctor_cmd.py +++ b/src/openjarvis/cli/doctor_cmd.py @@ -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 diff --git a/src/openjarvis/cli/log_config.py b/src/openjarvis/cli/log_config.py index 751a58fd..5c6f99bf 100644 --- a/src/openjarvis/cli/log_config.py +++ b/src/openjarvis/cli/log_config.py @@ -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) diff --git a/src/openjarvis/cli/serve.py b/src/openjarvis/cli/serve.py index 28d5e70a..a236e6b5 100644 --- a/src/openjarvis/cli/serve.py +++ b/src/openjarvis/cli/serve.py @@ -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") diff --git a/src/openjarvis/connectors/attachment_store.py b/src/openjarvis/connectors/attachment_store.py index 7b653562..770a6961 100644 --- a/src/openjarvis/connectors/attachment_store.py +++ b/src/openjarvis/connectors/attachment_store.py @@ -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( diff --git a/src/openjarvis/connectors/store.py b/src/openjarvis/connectors/store.py index 3d10312e..2e0cfbc8 100644 --- a/src/openjarvis/connectors/store.py +++ b/src/openjarvis/connectors/store.py @@ -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 diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index bce70fa3..dea8ab2c 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -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 diff --git a/src/openjarvis/core/credentials.py b/src/openjarvis/core/credentials.py index 04f95d70..6259de7f 100644 --- a/src/openjarvis/core/credentials.py +++ b/src/openjarvis/core/credentials.py @@ -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 diff --git a/src/openjarvis/engine/_stubs.py b/src/openjarvis/engine/_stubs.py index 04604c0e..4a940274 100644 --- a/src/openjarvis/engine/_stubs.py +++ b/src/openjarvis/engine/_stubs.py @@ -53,6 +53,7 @@ class InferenceEngine(ABC): """ engine_id: str + is_cloud: bool = False @abstractmethod def generate( diff --git a/src/openjarvis/engine/cloud.py b/src/openjarvis/engine/cloud.py index d58b131f..7a6340c0 100644 --- a/src/openjarvis/engine/cloud.py +++ b/src/openjarvis/engine/cloud.py @@ -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 diff --git a/src/openjarvis/engine/litellm.py b/src/openjarvis/engine/litellm.py index fb25dfdd..a90ec474 100644 --- a/src/openjarvis/engine/litellm.py +++ b/src/openjarvis/engine/litellm.py @@ -27,6 +27,7 @@ class LiteLLMEngine(InferenceEngine): """ engine_id = "litellm" + is_cloud = True def __init__( self, diff --git a/src/openjarvis/security/audit.py b/src/openjarvis/security/audit.py index dcb842ad..06a68d30 100644 --- a/src/openjarvis/security/audit.py +++ b/src/openjarvis/security/audit.py @@ -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( """ diff --git a/src/openjarvis/security/boundary.py b/src/openjarvis/security/boundary.py new file mode 100644 index 00000000..57c564fc --- /dev/null +++ b/src/openjarvis/security/boundary.py @@ -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) diff --git a/src/openjarvis/security/file_utils.py b/src/openjarvis/security/file_utils.py new file mode 100644 index 00000000..eb960fdf --- /dev/null +++ b/src/openjarvis/security/file_utils.py @@ -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 diff --git a/src/openjarvis/server/app.py b/src/openjarvis/server/app.py index 819fc85e..549e589c 100644 --- a/src/openjarvis/server/app.py +++ b/src/openjarvis/server/app.py @@ -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=["*"], diff --git a/src/openjarvis/server/auth_middleware.py b/src/openjarvis/server/auth_middleware.py index 7489428d..52e79b59 100644 --- a/src/openjarvis/server/auth_middleware.py +++ b/src/openjarvis/server/auth_middleware.py @@ -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) diff --git a/src/openjarvis/server/middleware.py b/src/openjarvis/server/middleware.py index 0d22ecb0..b31b7915 100644 --- a/src/openjarvis/server/middleware.py +++ b/src/openjarvis/server/middleware.py @@ -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'", } diff --git a/src/openjarvis/server/session_store.py b/src/openjarvis/server/session_store.py index fcea2601..892b1692 100644 --- a/src/openjarvis/server/session_store.py +++ b/src/openjarvis/server/session_store.py @@ -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() diff --git a/src/openjarvis/server/webhook_routes.py b/src/openjarvis/server/webhook_routes.py index 845abce9..ff24064c 100644 --- a/src/openjarvis/server/webhook_routes.py +++ b/src/openjarvis/server/webhook_routes.py @@ -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): diff --git a/src/openjarvis/system.py b/src/openjarvis/system.py index eda3ef5a..a9ade2f8 100644 --- a/src/openjarvis/system.py +++ b/src/openjarvis/system.py @@ -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 diff --git a/src/openjarvis/tools/_stubs.py b/src/openjarvis/tools/_stubs.py index 98a73d1f..127b4f3b 100644 --- a/src/openjarvis/tools/_stubs.py +++ b/src/openjarvis/tools/_stubs.py @@ -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: diff --git a/src/openjarvis/tools/audio_tool.py b/src/openjarvis/tools/audio_tool.py index 4fb77928..823c4260 100644 --- a/src/openjarvis/tools/audio_tool.py +++ b/src/openjarvis/tools/audio_tool.py @@ -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: diff --git a/src/openjarvis/tools/browser.py b/src/openjarvis/tools/browser.py index c026d587..a8f9211b 100644 --- a/src/openjarvis/tools/browser.py +++ b/src/openjarvis/tools/browser.py @@ -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: diff --git a/src/openjarvis/tools/browser_axtree.py b/src/openjarvis/tools/browser_axtree.py index ed0b7c58..84f65986 100644 --- a/src/openjarvis/tools/browser_axtree.py +++ b/src/openjarvis/tools/browser_axtree.py @@ -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: diff --git a/src/openjarvis/tools/channel_tools.py b/src/openjarvis/tools/channel_tools.py index aeb79b4c..7c5b2607 100644 --- a/src/openjarvis/tools/channel_tools.py +++ b/src/openjarvis/tools/channel_tools.py @@ -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 diff --git a/src/openjarvis/tools/http_request.py b/src/openjarvis/tools/http_request.py index fe61adbb..4206b09b 100644 --- a/src/openjarvis/tools/http_request.py +++ b/src/openjarvis/tools/http_request.py @@ -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: diff --git a/src/openjarvis/tools/image_tool.py b/src/openjarvis/tools/image_tool.py index 5b18b5ee..0e78140e 100644 --- a/src/openjarvis/tools/image_tool.py +++ b/src/openjarvis/tools/image_tool.py @@ -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: diff --git a/src/openjarvis/tools/web_search.py b/src/openjarvis/tools/web_search.py index 9b10bef3..fcf5fe1a 100644 --- a/src/openjarvis/tools/web_search.py +++ b/src/openjarvis/tools/web_search.py @@ -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") diff --git a/src/openjarvis/traces/store.py b/src/openjarvis/traces/store.py index 9c4ceb66..72a26a2e 100644 --- a/src/openjarvis/traces/store.py +++ b/src/openjarvis/traces/store.py @@ -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 diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 9e0556ec..3c179e88 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -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 diff --git a/tests/core/test_config_phase3.py b/tests/core/test_config_phase3.py index b2b0cb17..308b88c5 100644 --- a/tests/core/test_config_phase3.py +++ b/tests/core/test_config_phase3.py @@ -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 == "" diff --git a/tests/security/test_boundary_guard.py b/tests/security/test_boundary_guard.py new file mode 100644 index 00000000..85b22ad5 --- /dev/null +++ b/tests/security/test_boundary_guard.py @@ -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 diff --git a/tests/security/test_file_permissions.py b/tests/security/test_file_permissions.py new file mode 100644 index 00000000..758d020c --- /dev/null +++ b/tests/security/test_file_permissions.py @@ -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 diff --git a/tests/security/test_log_sanitization.py b/tests/security/test_log_sanitization.py new file mode 100644 index 00000000..e3c95546 --- /dev/null +++ b/tests/security/test_log_sanitization.py @@ -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 diff --git a/tests/security/test_network_defaults.py b/tests/security/test_network_defaults.py new file mode 100644 index 00000000..1b9d82ad --- /dev/null +++ b/tests/security/test_network_defaults.py @@ -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" diff --git a/tests/security/test_security_profiles.py b/tests/security/test_security_profiles.py new file mode 100644 index 00000000..8086ed69 --- /dev/null +++ b/tests/security/test_security_profiles.py @@ -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) diff --git a/tests/security/test_webhook_validation.py b/tests/security/test_webhook_validation.py new file mode 100644 index 00000000..9bd411ea --- /dev/null +++ b/tests/security/test_webhook_validation.py @@ -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 diff --git a/tests/server/test_middleware.py b/tests/server/test_middleware.py index d2016082..451cc973 100644 --- a/tests/server/test_middleware.py +++ b/tests/server/test_middleware.py @@ -19,6 +19,7 @@ class TestSecurityHeaders: "Strict-Transport-Security", "Referrer-Policy", "Permissions-Policy", + "Content-Security-Policy", } assert set(SECURITY_HEADERS.keys()) == expected_keys