omni-cortex 1.2.0__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. omni_cortex-1.4.0.data/data/share/omni-cortex/dashboard/backend/.env.example +22 -0
  2. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/dashboard/backend/chat_service.py +50 -29
  3. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/dashboard/backend/database.py +208 -0
  4. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/dashboard/backend/image_service.py +27 -11
  5. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/dashboard/backend/logging_config.py +34 -4
  6. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/dashboard/backend/main.py +138 -11
  7. omni_cortex-1.4.0.data/data/share/omni-cortex/dashboard/backend/prompt_security.py +111 -0
  8. omni_cortex-1.4.0.data/data/share/omni-cortex/dashboard/backend/security.py +104 -0
  9. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/dashboard/backend/uv.lock +414 -1
  10. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/hooks/pre_tool_use.py +46 -1
  11. {omni_cortex-1.2.0.dist-info → omni_cortex-1.4.0.dist-info}/METADATA +1 -1
  12. omni_cortex-1.4.0.dist-info/RECORD +23 -0
  13. omni_cortex-1.2.0.dist-info/RECORD +0 -20
  14. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/dashboard/backend/models.py +0 -0
  15. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/dashboard/backend/project_config.py +0 -0
  16. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/dashboard/backend/project_scanner.py +0 -0
  17. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/dashboard/backend/pyproject.toml +0 -0
  18. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/dashboard/backend/websocket_manager.py +0 -0
  19. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/hooks/post_tool_use.py +0 -0
  20. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/hooks/stop.py +0 -0
  21. {omni_cortex-1.2.0.data → omni_cortex-1.4.0.data}/data/share/omni-cortex/hooks/subagent_stop.py +0 -0
  22. {omni_cortex-1.2.0.dist-info → omni_cortex-1.4.0.dist-info}/WHEEL +0 -0
  23. {omni_cortex-1.2.0.dist-info → omni_cortex-1.4.0.dist-info}/entry_points.txt +0 -0
  24. {omni_cortex-1.2.0.dist-info → omni_cortex-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,6 +3,7 @@
3
3
 
4
4
  import asyncio
5
5
  import json
6
+ import os
6
7
  import traceback
7
8
  from contextlib import asynccontextmanager
8
9
  from datetime import datetime
@@ -10,19 +11,33 @@ from pathlib import Path
10
11
  from typing import Optional
11
12
 
12
13
  import uvicorn
13
- from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
14
+ from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect, Request, Depends
14
15
  from fastapi.middleware.cors import CORSMiddleware
15
16
  from fastapi.staticfiles import StaticFiles
16
- from fastapi.responses import FileResponse
17
+ from fastapi.responses import FileResponse, Response
18
+ from starlette.middleware.base import BaseHTTPMiddleware
17
19
  from watchdog.events import FileSystemEventHandler
18
20
  from watchdog.observers import Observer
19
21
 
22
+ # Rate limiting imports (optional - graceful degradation if not installed)
23
+ try:
24
+ from slowapi import Limiter, _rate_limit_exceeded_handler
25
+ from slowapi.util import get_remote_address
26
+ from slowapi.errors import RateLimitExceeded
27
+ RATE_LIMITING_AVAILABLE = True
28
+ except ImportError:
29
+ RATE_LIMITING_AVAILABLE = False
30
+ Limiter = None
31
+
20
32
  from database import (
21
33
  bulk_update_memory_status,
22
34
  delete_memory,
23
35
  get_activities,
36
+ get_activity_detail,
24
37
  get_activity_heatmap,
25
38
  get_all_tags,
39
+ get_command_usage,
40
+ get_mcp_usage,
26
41
  get_memories,
27
42
  get_memories_needing_review,
28
43
  get_memory_by_id,
@@ -32,6 +47,7 @@ from database import (
32
47
  get_relationship_graph,
33
48
  get_relationships,
34
49
  get_sessions,
50
+ get_skill_usage,
35
51
  get_timeline,
36
52
  get_tool_usage,
37
53
  get_type_distribution,
@@ -66,6 +82,48 @@ from project_scanner import scan_projects
66
82
  from websocket_manager import manager
67
83
  import chat_service
68
84
  from image_service import image_service, ImagePreset, SingleImageRequest
85
+ from security import PathValidator, get_cors_config, IS_PRODUCTION
86
+
87
+
88
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
89
+ """Add security headers to all responses."""
90
+
91
+ async def dispatch(self, request: Request, call_next) -> Response:
92
+ response = await call_next(request)
93
+
94
+ # Prevent MIME type sniffing
95
+ response.headers["X-Content-Type-Options"] = "nosniff"
96
+
97
+ # Prevent clickjacking
98
+ response.headers["X-Frame-Options"] = "DENY"
99
+
100
+ # XSS protection (legacy browsers)
101
+ response.headers["X-XSS-Protection"] = "1; mode=block"
102
+
103
+ # Content Security Policy
104
+ response.headers["Content-Security-Policy"] = (
105
+ "default-src 'self'; "
106
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " # Vue needs these
107
+ "style-src 'self' 'unsafe-inline'; " # Tailwind needs inline
108
+ "img-src 'self' data: blob: https:; " # Allow AI-generated images
109
+ "connect-src 'self' ws: wss: https://generativelanguage.googleapis.com; "
110
+ "font-src 'self'; "
111
+ "frame-ancestors 'none';"
112
+ )
113
+
114
+ # HSTS (only in production with HTTPS)
115
+ if IS_PRODUCTION and os.getenv("SSL_CERTFILE"):
116
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
117
+
118
+ return response
119
+
120
+
121
+ def validate_project_path(project: str = Query(..., description="Path to the database file")) -> Path:
122
+ """Validate project database path - dependency for endpoints."""
123
+ try:
124
+ return PathValidator.validate_project_path(project)
125
+ except ValueError as e:
126
+ raise HTTPException(status_code=400, detail=str(e))
69
127
 
70
128
 
71
129
  class DatabaseChangeHandler(FileSystemEventHandler):
@@ -133,13 +191,25 @@ app = FastAPI(
133
191
  lifespan=lifespan,
134
192
  )
135
193
 
136
- # CORS for frontend dev server
194
+ # Add security headers middleware (MUST come before CORS)
195
+ app.add_middleware(SecurityHeadersMiddleware)
196
+
197
+ # Rate limiting (if available)
198
+ if RATE_LIMITING_AVAILABLE:
199
+ limiter = Limiter(key_func=get_remote_address)
200
+ app.state.limiter = limiter
201
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
202
+ else:
203
+ limiter = None
204
+
205
+ # CORS configuration (environment-aware)
206
+ cors_config = get_cors_config()
137
207
  app.add_middleware(
138
208
  CORSMiddleware,
139
- allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
209
+ allow_origins=cors_config["allow_origins"],
140
210
  allow_credentials=True,
141
- allow_methods=["*"],
142
- allow_headers=["*"],
211
+ allow_methods=cors_config["allow_methods"],
212
+ allow_headers=cors_config["allow_headers"],
143
213
  )
144
214
 
145
215
  # Static files for production build
@@ -507,6 +577,63 @@ async def get_memory_growth_endpoint(
507
577
  return get_memory_growth(project, days)
508
578
 
509
579
 
580
+ # --- Command Analytics Endpoints ---
581
+
582
+
583
+ @app.get("/api/stats/command-usage")
584
+ async def get_command_usage_endpoint(
585
+ project: str = Query(..., description="Path to the database file"),
586
+ scope: Optional[str] = Query(None, description="Filter by scope: 'universal' or 'project'"),
587
+ days: int = Query(30, ge=1, le=365),
588
+ ):
589
+ """Get slash command usage statistics."""
590
+ if not Path(project).exists():
591
+ raise HTTPException(status_code=404, detail="Database not found")
592
+
593
+ return get_command_usage(project, scope, days)
594
+
595
+
596
+ @app.get("/api/stats/skill-usage")
597
+ async def get_skill_usage_endpoint(
598
+ project: str = Query(..., description="Path to the database file"),
599
+ scope: Optional[str] = Query(None, description="Filter by scope: 'universal' or 'project'"),
600
+ days: int = Query(30, ge=1, le=365),
601
+ ):
602
+ """Get skill usage statistics."""
603
+ if not Path(project).exists():
604
+ raise HTTPException(status_code=404, detail="Database not found")
605
+
606
+ return get_skill_usage(project, scope, days)
607
+
608
+
609
+ @app.get("/api/stats/mcp-usage")
610
+ async def get_mcp_usage_endpoint(
611
+ project: str = Query(..., description="Path to the database file"),
612
+ days: int = Query(30, ge=1, le=365),
613
+ ):
614
+ """Get MCP server usage statistics."""
615
+ if not Path(project).exists():
616
+ raise HTTPException(status_code=404, detail="Database not found")
617
+
618
+ return get_mcp_usage(project, days)
619
+
620
+
621
+ @app.get("/api/activities/{activity_id}")
622
+ async def get_activity_detail_endpoint(
623
+ activity_id: str,
624
+ project: str = Query(..., description="Path to the database file"),
625
+ ):
626
+ """Get full activity details including complete input/output."""
627
+ if not Path(project).exists():
628
+ raise HTTPException(status_code=404, detail="Database not found")
629
+
630
+ activity = get_activity_detail(project, activity_id)
631
+ if not activity:
632
+ raise HTTPException(status_code=404, detail="Activity not found")
633
+
634
+ return activity
635
+
636
+
510
637
  # --- Session Context Endpoints ---
511
638
 
512
639
 
@@ -910,15 +1037,15 @@ async def serve_root():
910
1037
 
911
1038
  @app.get("/{path:path}")
912
1039
  async def serve_spa(path: str):
913
- """Catch-all route to serve SPA for client-side routing."""
1040
+ """Catch-all route to serve SPA for client-side routing with path traversal protection."""
914
1041
  # Skip API routes and known paths
915
1042
  if path.startswith(("api/", "ws", "health", "docs", "openapi", "redoc")):
916
1043
  raise HTTPException(status_code=404, detail="Not found")
917
1044
 
918
- # Check if it's a static file
919
- file_path = DIST_DIR / path
920
- if file_path.exists() and file_path.is_file():
921
- return FileResponse(str(file_path))
1045
+ # Check if it's a static file (with path traversal protection)
1046
+ safe_path = PathValidator.is_safe_static_path(DIST_DIR, path)
1047
+ if safe_path:
1048
+ return FileResponse(str(safe_path))
922
1049
 
923
1050
  # Otherwise serve index.html for SPA routing
924
1051
  index_file = DIST_DIR / "index.html"
@@ -0,0 +1,111 @@
1
+ """Prompt injection protection for Omni-Cortex."""
2
+
3
+ import re
4
+ import logging
5
+ from html import escape as html_escape
6
+ from typing import Optional
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def xml_escape(text: str) -> str:
12
+ """Escape text for safe inclusion in XML-structured prompts.
13
+
14
+ Converts special characters to prevent prompt injection via
15
+ XML/HTML-like delimiters.
16
+ """
17
+ return html_escape(text, quote=True)
18
+
19
+
20
+ def build_safe_prompt(
21
+ system_instruction: str,
22
+ user_data: dict[str, str],
23
+ user_question: str
24
+ ) -> str:
25
+ """Build a prompt with clear instruction/data separation.
26
+
27
+ Uses XML tags to separate trusted instructions from untrusted data,
28
+ making it harder for injected content to be interpreted as instructions.
29
+
30
+ Args:
31
+ system_instruction: Trusted system prompt (not escaped)
32
+ user_data: Dict of data sections to include (escaped)
33
+ user_question: User's question (escaped)
34
+
35
+ Returns:
36
+ Safely structured prompt string
37
+ """
38
+ parts = [system_instruction, ""]
39
+
40
+ # Add data sections with XML escaping
41
+ for section_name, content in user_data.items():
42
+ if content:
43
+ parts.append(f"<{section_name}>")
44
+ parts.append(xml_escape(content))
45
+ parts.append(f"</{section_name}>")
46
+ parts.append("")
47
+
48
+ # Add user question
49
+ parts.append("<user_question>")
50
+ parts.append(xml_escape(user_question))
51
+ parts.append("</user_question>")
52
+
53
+ return "\n".join(parts)
54
+
55
+
56
+ # Known prompt injection patterns
57
+ INJECTION_PATTERNS = [
58
+ (r'(?i)(ignore|disregard|forget)\s+(all\s+)?(previous|prior|above)\s+instructions?',
59
+ 'instruction override attempt'),
60
+ (r'(?i)(new\s+)?system\s+(prompt|instruction|message)',
61
+ 'system prompt manipulation'),
62
+ (r'(?i)you\s+(must|should|will|are\s+required\s+to)\s+now',
63
+ 'imperative command injection'),
64
+ (r'(?i)(hidden|secret|special)\s+instruction',
65
+ 'hidden instruction claim'),
66
+ (r'(?i)\[/?system\]|\[/?inst\]|<\/?system>|<\/?instruction>',
67
+ 'fake delimiter injection'),
68
+ (r'(?i)bypass|jailbreak|DAN|GODMODE',
69
+ 'known jailbreak signature'),
70
+ ]
71
+
72
+
73
+ def detect_injection_patterns(content: str) -> list[str]:
74
+ """Detect potential prompt injection patterns in content.
75
+
76
+ Returns list of detected patterns (empty if clean).
77
+ """
78
+ detected = []
79
+ for pattern, description in INJECTION_PATTERNS:
80
+ if re.search(pattern, content):
81
+ detected.append(description)
82
+
83
+ return detected
84
+
85
+
86
+ def sanitize_memory_content(content: str, warn_on_detection: bool = True) -> tuple[str, list[str]]:
87
+ """Sanitize memory content and detect injection attempts.
88
+
89
+ Args:
90
+ content: Raw memory content
91
+ warn_on_detection: If True, log warnings for detected patterns
92
+
93
+ Returns:
94
+ Tuple of (sanitized_content, list_of_detected_patterns)
95
+ """
96
+ detected = detect_injection_patterns(content)
97
+
98
+ if detected and warn_on_detection:
99
+ logger.warning(f"Potential injection patterns detected: {detected}")
100
+
101
+ # Content is still returned - we sanitize via XML escaping when used in prompts
102
+ return content, detected
103
+
104
+
105
+ def sanitize_context_data(data: str) -> str:
106
+ """Escape context data for safe inclusion in prompts.
107
+
108
+ This is the primary defense - all user-supplied data should be
109
+ escaped before inclusion in prompts to prevent injection.
110
+ """
111
+ return xml_escape(data)
@@ -0,0 +1,104 @@
1
+ """Security utilities for Omni-Cortex Dashboard."""
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ class PathValidator:
10
+ """Validate and sanitize file paths to prevent traversal attacks."""
11
+
12
+ # Pattern for valid omni-cortex database paths
13
+ VALID_DB_PATTERN = re.compile(r'^.*[/\\]\.omni-cortex[/\\]cortex\.db$')
14
+ GLOBAL_DB_PATTERN = re.compile(r'^.*[/\\]\.omni-cortex[/\\]global\.db$')
15
+
16
+ @staticmethod
17
+ def is_valid_project_db(path: str) -> bool:
18
+ """Check if path is a valid omni-cortex project database."""
19
+ try:
20
+ resolved = Path(path).resolve()
21
+ path_str = str(resolved)
22
+
23
+ # Must match expected patterns
24
+ if PathValidator.VALID_DB_PATTERN.match(path_str):
25
+ return resolved.exists() and resolved.is_file()
26
+ if PathValidator.GLOBAL_DB_PATTERN.match(path_str):
27
+ return resolved.exists() and resolved.is_file()
28
+
29
+ return False
30
+ except (ValueError, OSError):
31
+ return False
32
+
33
+ @staticmethod
34
+ def validate_project_path(path: str) -> Path:
35
+ """Validate and return resolved path, or raise ValueError."""
36
+ if not PathValidator.is_valid_project_db(path):
37
+ raise ValueError(f"Invalid project database path: {path}")
38
+ return Path(path).resolve()
39
+
40
+ @staticmethod
41
+ def is_safe_static_path(base_dir: Path, requested_path: str) -> Optional[Path]:
42
+ """Validate static file path is within base directory.
43
+
44
+ Returns resolved path if safe, None if traversal detected.
45
+ """
46
+ try:
47
+ # Resolve both paths to absolute
48
+ base_resolved = base_dir.resolve()
49
+ requested = (base_dir / requested_path).resolve()
50
+
51
+ # Check if requested path is under base directory
52
+ if base_resolved in requested.parents or requested == base_resolved:
53
+ if requested.exists() and requested.is_file():
54
+ return requested
55
+
56
+ return None
57
+ except (ValueError, OSError):
58
+ return None
59
+
60
+
61
+ def sanitize_log_input(value: str, max_length: int = 200) -> str:
62
+ """Sanitize user input for safe logging.
63
+
64
+ Prevents log injection by:
65
+ - Escaping newlines
66
+ - Limiting length
67
+ - Removing control characters
68
+ """
69
+ if not isinstance(value, str):
70
+ value = str(value)
71
+
72
+ # Remove control characters except spaces
73
+ sanitized = ''.join(c if c.isprintable() or c == ' ' else '?' for c in value)
74
+
75
+ # Escape potential log injection patterns
76
+ sanitized = sanitized.replace('\n', '\\n').replace('\r', '\\r')
77
+
78
+ # Truncate
79
+ if len(sanitized) > max_length:
80
+ sanitized = sanitized[:max_length] + '...'
81
+
82
+ return sanitized
83
+
84
+
85
+ # Environment-based configuration
86
+ IS_PRODUCTION = os.getenv("ENVIRONMENT", "development") == "production"
87
+
88
+
89
+ def get_cors_config():
90
+ """Get CORS configuration based on environment."""
91
+ if IS_PRODUCTION:
92
+ origins = os.getenv("CORS_ORIGINS", "").split(",")
93
+ origins = [o.strip() for o in origins if o.strip()]
94
+ return {
95
+ "allow_origins": origins,
96
+ "allow_methods": ["GET", "POST", "PUT", "DELETE"],
97
+ "allow_headers": ["Content-Type", "Authorization", "X-API-Key"],
98
+ }
99
+ else:
100
+ return {
101
+ "allow_origins": ["http://localhost:5173", "http://127.0.0.1:5173"],
102
+ "allow_methods": ["*"],
103
+ "allow_headers": ["*"],
104
+ }