loki-mode 5.40.0 → 5.41.0

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.
@@ -11,10 +11,12 @@ Usage:
11
11
  """
12
12
 
13
13
  import asyncio
14
+ import fcntl
14
15
  import json
15
16
  import os
16
17
  import signal
17
18
  import subprocess
19
+ import tempfile
18
20
  from datetime import datetime, timezone
19
21
  from pathlib import Path
20
22
  from typing import Optional
@@ -49,6 +51,57 @@ RUN_SH = SKILL_DIR / "autonomy" / "run.sh"
49
51
  STATE_DIR.mkdir(parents=True, exist_ok=True)
50
52
  LOG_DIR.mkdir(parents=True, exist_ok=True)
51
53
 
54
+ # Utility: atomic write with optional file locking
55
+ def atomic_write_json(file_path: Path, data: dict, use_lock: bool = True):
56
+ """
57
+ Atomically write JSON data to a file to prevent TOCTOU race conditions.
58
+ Uses temporary file + os.rename() for atomicity.
59
+ Optionally uses fcntl.flock for additional safety.
60
+ """
61
+ try:
62
+ # Write to temporary file in same directory (for atomic rename)
63
+ temp_fd, temp_path = tempfile.mkstemp(
64
+ dir=file_path.parent,
65
+ prefix=f".{file_path.name}.",
66
+ suffix=".tmp"
67
+ )
68
+
69
+ try:
70
+ with os.fdopen(temp_fd, 'w') as f:
71
+ # Acquire exclusive lock if requested
72
+ if use_lock:
73
+ try:
74
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
75
+ except (OSError, AttributeError):
76
+ # flock not available on this platform - continue without lock
77
+ pass
78
+
79
+ # Write data
80
+ json.dump(data, f, indent=2)
81
+ f.flush()
82
+ os.fsync(f.fileno())
83
+
84
+ # Release lock (happens automatically on close, but explicit is clearer)
85
+ if use_lock:
86
+ try:
87
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
88
+ except (OSError, AttributeError):
89
+ pass
90
+
91
+ # Atomic rename
92
+ os.rename(temp_path, file_path)
93
+
94
+ except Exception:
95
+ # Clean up temp file on error
96
+ try:
97
+ os.unlink(temp_path)
98
+ except OSError:
99
+ pass
100
+ raise
101
+
102
+ except Exception as e:
103
+ raise RuntimeError(f"Failed to write {file_path}: {e}")
104
+
52
105
  # FastAPI app
53
106
  app = FastAPI(
54
107
  title="Loki Mode Control API",
@@ -77,6 +130,42 @@ class StartRequest(BaseModel):
77
130
  parallel: bool = False
78
131
  background: bool = True
79
132
 
133
+ def validate_provider(self) -> None:
134
+ """Validate provider is from allowed list."""
135
+ allowed_providers = ["claude", "codex", "gemini"]
136
+ if self.provider not in allowed_providers:
137
+ raise ValueError(f"Invalid provider: {self.provider}. Must be one of: {', '.join(allowed_providers)}")
138
+
139
+ def validate_prd_path(self) -> None:
140
+ """Validate PRD path is safe and exists."""
141
+ if not self.prd:
142
+ return
143
+
144
+ # Check for path traversal sequences
145
+ if ".." in self.prd:
146
+ raise ValueError("PRD path contains path traversal sequence (..)")
147
+
148
+ # Resolve to absolute path and verify it exists
149
+ prd_path = Path(self.prd).resolve()
150
+ if not prd_path.exists():
151
+ raise ValueError(f"PRD file does not exist: {self.prd}")
152
+
153
+ # Verify it's a file, not a directory
154
+ if not prd_path.is_file():
155
+ raise ValueError(f"PRD path is not a file: {self.prd}")
156
+
157
+ # Verify path resolves within CWD or a reasonable parent
158
+ cwd = Path.cwd().resolve()
159
+ try:
160
+ prd_path.relative_to(cwd)
161
+ except ValueError:
162
+ # Not within CWD - check if it's within user's home or project directory
163
+ home = Path.home().resolve()
164
+ try:
165
+ prd_path.relative_to(home)
166
+ except ValueError:
167
+ raise ValueError(f"PRD path is outside allowed directories: {self.prd}")
168
+
80
169
 
81
170
  class StatusResponse(BaseModel):
82
171
  """Current session status."""
@@ -272,6 +361,13 @@ async def start_session(request: StartRequest):
272
361
  Returns:
273
362
  ControlResponse with success status and PID
274
363
  """
364
+ # Validate input
365
+ try:
366
+ request.validate_provider()
367
+ request.validate_prd_path()
368
+ except ValueError as e:
369
+ raise HTTPException(status_code=400, detail=str(e))
370
+
275
371
  # Check if already running
276
372
  status = get_status()
277
373
  if status.state == "running":
@@ -356,10 +452,13 @@ async def stop_session():
356
452
  session_file = LOKI_DIR / "session.json"
357
453
  if session_file.exists():
358
454
  try:
455
+ # Read current session data
359
456
  session_data = json.loads(session_file.read_text())
360
457
  session_data["status"] = "stopped"
361
- session_file.write_text(json.dumps(session_data))
362
- except (json.JSONDecodeError, KeyError):
458
+
459
+ # Atomic write with file locking to prevent race conditions
460
+ atomic_write_json(session_file, session_data, use_lock=True)
461
+ except (json.JSONDecodeError, KeyError, RuntimeError):
363
462
  pass
364
463
 
365
464
  # Emit stop event
@@ -44,7 +44,7 @@ def _save_registry(registry: dict) -> None:
44
44
 
45
45
  def _generate_project_id(path: str) -> str:
46
46
  """Generate a unique project ID from path."""
47
- return hashlib.md5(path.encode()).hexdigest()[:12]
47
+ return hashlib.sha256(path.encode()).hexdigest()[:12]
48
48
 
49
49
 
50
50
  def register_project(
@@ -286,7 +286,8 @@ def discover_projects(
286
286
 
287
287
  # Search subdirectories
288
288
  for child in path.iterdir():
289
- if child.is_dir() and not child.name.startswith("."):
289
+ # Skip symlinks to avoid following into unexpected directories
290
+ if child.is_dir() and not child.name.startswith(".") and not child.is_symlink():
290
291
  search_dir(child, depth + 1)
291
292
 
292
293
  except (PermissionError, OSError):
@@ -68,14 +68,33 @@ LOKI_TLS_KEY = os.environ.get("LOKI_TLS_KEY", "") # Path to PEM private key
68
68
  class _RateLimiter:
69
69
  """Simple in-memory rate limiter for control endpoints."""
70
70
 
71
- def __init__(self, max_calls: int = 10, window_seconds: int = 60):
71
+ def __init__(self, max_calls: int = 10, window_seconds: int = 60, max_keys: int = 10000):
72
72
  self._max_calls = max_calls
73
73
  self._window = window_seconds
74
+ self._max_keys = max_keys
74
75
  self._calls: dict[str, list[float]] = defaultdict(list)
75
76
 
76
77
  def check(self, key: str) -> bool:
77
78
  now = time.time()
79
+ # Prune old timestamps for this key
78
80
  self._calls[key] = [t for t in self._calls[key] if now - t < self._window]
81
+
82
+ # Remove keys with empty timestamp lists
83
+ empty_keys = [k for k, v in self._calls.items() if not v]
84
+ for k in empty_keys:
85
+ del self._calls[k]
86
+
87
+ # Evict oldest keys if max_keys exceeded
88
+ if len(self._calls) > self._max_keys:
89
+ # Sort by oldest timestamp, remove oldest keys
90
+ sorted_keys = sorted(
91
+ self._calls.items(),
92
+ key=lambda x: min(x[1]) if x[1] else 0
93
+ )
94
+ keys_to_remove = len(self._calls) - self._max_keys
95
+ for k, _ in sorted_keys[:keys_to_remove]:
96
+ del self._calls[k]
97
+
79
98
  if len(self._calls[key]) >= self._max_calls:
80
99
  return False
81
100
  self._calls[key].append(now)
@@ -83,6 +102,7 @@ class _RateLimiter:
83
102
 
84
103
 
85
104
  _control_limiter = _RateLimiter(max_calls=10, window_seconds=60)
105
+ _read_limiter = _RateLimiter(max_calls=60, window_seconds=60)
86
106
 
87
107
  # Set up logging
88
108
  logger = logging.getLogger(__name__)
@@ -193,11 +213,18 @@ class StatusResponse(BaseModel):
193
213
  class ConnectionManager:
194
214
  """Manages WebSocket connections for real-time updates."""
195
215
 
216
+ MAX_CONNECTIONS = int(os.environ.get("LOKI_MAX_WS_CONNECTIONS", "100"))
217
+
196
218
  def __init__(self):
197
219
  self.active_connections: list[WebSocket] = []
198
220
 
199
221
  async def connect(self, websocket: WebSocket) -> None:
200
222
  """Accept a new WebSocket connection."""
223
+ if len(self.active_connections) >= self.MAX_CONNECTIONS:
224
+ await websocket.accept()
225
+ await websocket.close(code=1013, reason="Connection limit reached. Try again later.")
226
+ logger.warning(f"WebSocket connection rejected: limit of {self.MAX_CONNECTIONS} reached")
227
+ return
201
228
  await websocket.accept()
202
229
  self.active_connections.append(websocket)
203
230
 
@@ -853,6 +880,13 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
853
880
  # Authorization headers on WS upgrade. Tokens may appear in reverse
854
881
  # proxy access logs -- configure log sanitization for /ws in production.
855
882
  # FastAPI Depends() is not supported on @app.websocket() routes.
883
+
884
+ # Rate limit WebSocket connections by IP
885
+ client_ip = websocket.client.host if websocket.client else "unknown"
886
+ if not _read_limiter.check(f"ws_{client_ip}"):
887
+ await websocket.close(code=1008) # Policy Violation
888
+ return
889
+
856
890
  if auth.is_enterprise_mode() or auth.is_oidc_mode():
857
891
  ws_token: Optional[str] = websocket.query_params.get("token")
858
892
  if not ws_token:
@@ -1019,8 +1053,9 @@ async def update_project_access(identifier: str):
1019
1053
 
1020
1054
 
1021
1055
  @app.get("/api/registry/discover", response_model=list[DiscoverResponse])
1022
- async def discover_projects(max_depth: int = 3):
1056
+ async def discover_projects(max_depth: int = Query(default=3, ge=1, le=10)):
1023
1057
  """Discover projects with .loki directories."""
1058
+ max_depth = min(max_depth, 10)
1024
1059
  discovered = registry.discover_projects(max_depth=max_depth)
1025
1060
  return discovered
1026
1061
 
@@ -1028,6 +1063,9 @@ async def discover_projects(max_depth: int = 3):
1028
1063
  @app.post("/api/registry/sync", response_model=SyncResponse)
1029
1064
  async def sync_registry():
1030
1065
  """Sync the registry with discovered projects."""
1066
+ if not _read_limiter.check("registry_sync"):
1067
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
1068
+
1031
1069
  result = registry.sync_registry_with_discovery()
1032
1070
  return {
1033
1071
  "added": result["added"],
@@ -1109,6 +1147,9 @@ async def create_token(request: TokenCreateRequest):
1109
1147
 
1110
1148
  The raw token is only returned once on creation - save it securely.
1111
1149
  """
1150
+ if not _read_limiter.check("token_create"):
1151
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
1152
+
1112
1153
  if not auth.is_enterprise_mode():
1113
1154
  raise HTTPException(
1114
1155
  status_code=403,
@@ -1562,6 +1603,9 @@ async def get_learning_aggregation():
1562
1603
  @app.post("/api/learning/aggregate", dependencies=[Depends(auth.require_scope("control"))])
1563
1604
  async def trigger_aggregation():
1564
1605
  """Aggregate learning signals from events.jsonl into structured metrics."""
1606
+ if not _read_limiter.check("learning_aggregate"):
1607
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
1608
+
1565
1609
  events_file = _get_loki_dir() / "events.jsonl"
1566
1610
  preferences: dict = {}
1567
1611
  error_patterns: dict = {}
@@ -1693,17 +1737,34 @@ def _parse_time_range(time_range: str) -> Optional[datetime]:
1693
1737
  return datetime.now(timezone.utc) - delta
1694
1738
 
1695
1739
 
1696
- def _read_events(time_range: str = "7d") -> list:
1697
- """Read events from .loki/events.jsonl with time filter."""
1740
+ def _read_events(time_range: str = "7d", max_events: int = 10000) -> list:
1741
+ """Read events from .loki/events.jsonl with time filter and size limits."""
1698
1742
  events_file = _get_loki_dir() / "events.jsonl"
1699
1743
  if not events_file.exists():
1700
1744
  return []
1701
1745
 
1702
1746
  cutoff = _parse_time_range(time_range)
1703
1747
  events = []
1748
+ max_file_size = 10 * 1024 * 1024 # 10MB
1749
+
1704
1750
  try:
1705
- for line in events_file.read_text().strip().split("\n"):
1706
- if line.strip():
1751
+ file_size = events_file.stat().st_size
1752
+
1753
+ # If file > 10MB, seek to last 10MB
1754
+ with open(events_file, 'r') as f:
1755
+ if file_size > max_file_size:
1756
+ f.seek(max(0, file_size - max_file_size))
1757
+ # Skip partial first line after seek
1758
+ f.readline()
1759
+
1760
+ for line in f:
1761
+ if len(events) >= max_events:
1762
+ break
1763
+
1764
+ line = line.strip()
1765
+ if not line:
1766
+ continue
1767
+
1707
1768
  try:
1708
1769
  event = json.loads(line)
1709
1770
  # Filter by time_range if cutoff was parsed successfully
@@ -2728,6 +2789,130 @@ async def get_secrets_status():
2728
2789
  }
2729
2790
 
2730
2791
 
2792
+ # =============================================================================
2793
+ # GitHub Integration API (v5.41.0)
2794
+ # =============================================================================
2795
+
2796
+
2797
+ @app.get("/api/github/status")
2798
+ async def get_github_status(token: Optional[dict] = Depends(auth.get_current_token)):
2799
+ """Get GitHub integration status and configuration."""
2800
+ loki_dir = _get_loki_dir()
2801
+ result: dict[str, Any] = {
2802
+ "import_enabled": os.environ.get("LOKI_GITHUB_IMPORT", "false") == "true",
2803
+ "sync_enabled": os.environ.get("LOKI_GITHUB_SYNC", "false") == "true",
2804
+ "pr_enabled": os.environ.get("LOKI_GITHUB_PR", "false") == "true",
2805
+ "labels_filter": os.environ.get("LOKI_GITHUB_LABELS", ""),
2806
+ "milestone_filter": os.environ.get("LOKI_GITHUB_MILESTONE", ""),
2807
+ "limit": int(os.environ.get("LOKI_GITHUB_LIMIT", "100")),
2808
+ "imported_tasks": 0,
2809
+ "synced_updates": 0,
2810
+ "repo": None,
2811
+ }
2812
+
2813
+ # Count imported GitHub tasks from pending queue
2814
+ pending_file = loki_dir / "queue" / "pending.json"
2815
+ if pending_file.exists():
2816
+ try:
2817
+ data = json.loads(pending_file.read_text())
2818
+ tasks = data.get("tasks", data) if isinstance(data, dict) else data
2819
+ result["imported_tasks"] = sum(1 for t in tasks if t.get("source") == "github")
2820
+ except Exception:
2821
+ pass
2822
+
2823
+ # Count sync log entries
2824
+ sync_log = loki_dir / "github" / "synced.log"
2825
+ if sync_log.exists():
2826
+ try:
2827
+ result["synced_updates"] = sum(1 for _ in sync_log.open())
2828
+ except Exception:
2829
+ pass
2830
+
2831
+ # Detect repo from git
2832
+ try:
2833
+ import subprocess
2834
+ url = subprocess.run(
2835
+ ["git", "remote", "get-url", "origin"],
2836
+ capture_output=True, text=True, timeout=5,
2837
+ cwd=str(loki_dir.parent) if loki_dir.name == ".loki" else None
2838
+ )
2839
+ if url.returncode == 0:
2840
+ repo = url.stdout.strip()
2841
+ # Parse owner/repo from URL
2842
+ for prefix in ["https://github.com/", "git@github.com:"]:
2843
+ if repo.startswith(prefix):
2844
+ repo = repo[len(prefix):]
2845
+ break
2846
+ result["repo"] = repo.removesuffix(".git")
2847
+ except Exception:
2848
+ pass
2849
+
2850
+ return result
2851
+
2852
+
2853
+ @app.get("/api/github/tasks")
2854
+ async def get_github_tasks(token: Optional[dict] = Depends(auth.get_current_token)):
2855
+ """Get all GitHub-sourced tasks and their sync status."""
2856
+ loki_dir = _get_loki_dir()
2857
+ tasks: list[dict] = []
2858
+
2859
+ # Collect GitHub tasks from all queues
2860
+ for queue_name in ["pending", "in-progress", "completed", "failed"]:
2861
+ queue_file = loki_dir / "queue" / f"{queue_name}.json"
2862
+ if queue_file.exists():
2863
+ try:
2864
+ data = json.loads(queue_file.read_text())
2865
+ items = data.get("tasks", data) if isinstance(data, dict) else data
2866
+ for t in items:
2867
+ if t.get("source") == "github" or str(t.get("id", "")).startswith("github-"):
2868
+ t["queue"] = queue_name
2869
+ tasks.append(t)
2870
+ except Exception:
2871
+ pass
2872
+
2873
+ # Load sync log to annotate sync status
2874
+ synced: set[str] = set()
2875
+ sync_log = loki_dir / "github" / "synced.log"
2876
+ if sync_log.exists():
2877
+ try:
2878
+ synced = set(sync_log.read_text().strip().splitlines())
2879
+ except Exception:
2880
+ pass
2881
+
2882
+ for t in tasks:
2883
+ issue_num = str(t.get("github_issue", ""))
2884
+ if not issue_num:
2885
+ issue_num = str(t.get("id", "")).replace("github-", "")
2886
+ t["synced_statuses"] = [
2887
+ s.split(":")[1] for s in synced if s.startswith(f"{issue_num}:")
2888
+ ]
2889
+
2890
+ return {"tasks": tasks, "total": len(tasks)}
2891
+
2892
+
2893
+ @app.get("/api/github/sync-log")
2894
+ async def get_github_sync_log(
2895
+ limit: int = Query(default=50, ge=1, le=500),
2896
+ token: Optional[dict] = Depends(auth.get_current_token)
2897
+ ):
2898
+ """Get the GitHub sync log (status updates sent to issues)."""
2899
+ loki_dir = _get_loki_dir()
2900
+ sync_log = loki_dir / "github" / "synced.log"
2901
+ entries: list[dict] = []
2902
+
2903
+ if sync_log.exists():
2904
+ try:
2905
+ lines = sync_log.read_text().strip().splitlines()
2906
+ for line in lines[-limit:]:
2907
+ parts = line.split(":", 1)
2908
+ if len(parts) == 2:
2909
+ entries.append({"issue": parts[0], "status": parts[1]})
2910
+ except Exception:
2911
+ pass
2912
+
2913
+ return {"entries": entries, "total": len(entries)}
2914
+
2915
+
2731
2916
  # =============================================================================
2732
2917
  # Process Health / Watchdog API
2733
2918
  # =============================================================================