loki-mode 5.46.0 → 5.48.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.
@@ -26,7 +26,7 @@ from fastapi import (
26
26
  WebSocketDisconnect,
27
27
  )
28
28
  from fastapi.middleware.cors import CORSMiddleware
29
- from fastapi.responses import PlainTextResponse
29
+ from fastapi.responses import JSONResponse, PlainTextResponse
30
30
  from pydantic import BaseModel, Field
31
31
  from sqlalchemy import select, update, delete
32
32
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -62,6 +62,15 @@ except ImportError:
62
62
  LOKI_TLS_CERT = os.environ.get("LOKI_TLS_CERT", "") # Path to PEM certificate
63
63
  LOKI_TLS_KEY = os.environ.get("LOKI_TLS_KEY", "") # Path to PEM private key
64
64
 
65
+
66
+ def _safe_int_env(name: str, default: int) -> int:
67
+ """Read an integer from an environment variable, returning *default* on bad values."""
68
+ try:
69
+ return int(os.environ.get(name, str(default)))
70
+ except (ValueError, TypeError):
71
+ return default
72
+
73
+
65
74
  # ---------------------------------------------------------------------------
66
75
  # Simple in-memory rate limiter for control endpoints
67
76
  # ---------------------------------------------------------------------------
@@ -214,7 +223,10 @@ class StatusResponse(BaseModel):
214
223
  class ConnectionManager:
215
224
  """Manages WebSocket connections for real-time updates."""
216
225
 
217
- MAX_CONNECTIONS = int(os.environ.get("LOKI_MAX_WS_CONNECTIONS", "100"))
226
+ try:
227
+ MAX_CONNECTIONS = int(os.environ.get("LOKI_MAX_WS_CONNECTIONS", "100"))
228
+ except (ValueError, TypeError):
229
+ MAX_CONNECTIONS = 100
218
230
 
219
231
  def __init__(self):
220
232
  self.active_connections: list[WebSocket] = []
@@ -493,7 +505,7 @@ async def list_projects(
493
505
  return response
494
506
 
495
507
 
496
- @app.post("/api/projects", response_model=ProjectResponse, status_code=201)
508
+ @app.post("/api/projects", response_model=ProjectResponse, status_code=201, dependencies=[Depends(auth.require_scope("control"))])
497
509
  async def create_project(
498
510
  project: ProjectCreate,
499
511
  db: AsyncSession = Depends(get_db),
@@ -718,7 +730,7 @@ async def list_tasks(
718
730
  return all_tasks
719
731
 
720
732
 
721
- @app.post("/api/tasks", response_model=TaskResponse, status_code=201)
733
+ @app.post("/api/tasks", response_model=TaskResponse, status_code=201, dependencies=[Depends(auth.require_scope("control"))])
722
734
  async def create_task(
723
735
  task: TaskCreate,
724
736
  db: AsyncSession = Depends(get_db),
@@ -1041,7 +1053,7 @@ async def list_registered_projects(include_inactive: bool = False):
1041
1053
  return projects
1042
1054
 
1043
1055
 
1044
- @app.post("/api/registry/projects", response_model=RegisteredProjectResponse, status_code=201)
1056
+ @app.post("/api/registry/projects", response_model=RegisteredProjectResponse, status_code=201, dependencies=[Depends(auth.require_scope("control"))])
1045
1057
  async def register_project(request: RegisterProjectRequest):
1046
1058
  """Register a new project."""
1047
1059
  try:
@@ -1087,7 +1099,7 @@ async def get_project_health(identifier: str):
1087
1099
  return health
1088
1100
 
1089
1101
 
1090
- @app.post("/api/registry/projects/{identifier}/access")
1102
+ @app.post("/api/registry/projects/{identifier}/access", dependencies=[Depends(auth.require_scope("control"))])
1091
1103
  async def update_project_access(identifier: str):
1092
1104
  """Update the last accessed timestamp for a project."""
1093
1105
  project = registry.update_last_accessed(identifier)
@@ -1104,7 +1116,7 @@ async def discover_projects(max_depth: int = Query(default=3, ge=1, le=10)):
1104
1116
  return discovered
1105
1117
 
1106
1118
 
1107
- @app.post("/api/registry/sync", response_model=SyncResponse)
1119
+ @app.post("/api/registry/sync", response_model=SyncResponse, dependencies=[Depends(auth.require_scope("control"))])
1108
1120
  async def sync_registry():
1109
1121
  """Sync the registry with discovered projects."""
1110
1122
  if not _read_limiter.check("registry_sync"):
@@ -1792,7 +1804,17 @@ async def trigger_aggregation():
1792
1804
 
1793
1805
  if events_file.exists():
1794
1806
  try:
1795
- for raw_line in events_file.read_text().strip().split("\n"):
1807
+ # Guard against unbounded reads: if file > 10 MB, read only the tail
1808
+ _MAX_EVENTS_BYTES = 10 * 1024 * 1024 # 10 MB
1809
+ _fsize = events_file.stat().st_size
1810
+ if _fsize > _MAX_EVENTS_BYTES:
1811
+ with open(events_file, "rb") as _fh:
1812
+ _fh.seek(-_MAX_EVENTS_BYTES, 2)
1813
+ _fh.readline() # discard partial first line
1814
+ _raw_text = _fh.read().decode("utf-8", errors="replace")
1815
+ else:
1816
+ _raw_text = events_file.read_text()
1817
+ for raw_line in _raw_text.strip().split("\n"):
1796
1818
  if not raw_line.strip():
1797
1819
  continue
1798
1820
  try:
@@ -2396,8 +2418,8 @@ async def get_council_convergence():
2396
2418
  convergence_file = _get_loki_dir() / "council" / "convergence.log"
2397
2419
  data_points = []
2398
2420
  if convergence_file.exists():
2399
- try:
2400
- for line in convergence_file.read_text().strip().split("\n"):
2421
+ for line in convergence_file.read_text().strip().split("\n"):
2422
+ try:
2401
2423
  parts = line.split("|")
2402
2424
  if len(parts) >= 5:
2403
2425
  data_points.append({
@@ -2407,8 +2429,8 @@ async def get_council_convergence():
2407
2429
  "no_change_streak": int(parts[3]),
2408
2430
  "done_signals": int(parts[4]),
2409
2431
  })
2410
- except Exception:
2411
- pass
2432
+ except Exception:
2433
+ continue
2412
2434
  return {"dataPoints": data_points}
2413
2435
 
2414
2436
 
@@ -3002,7 +3024,7 @@ async def get_github_status(token: Optional[dict] = Depends(auth.get_current_tok
3002
3024
  "pr_enabled": os.environ.get("LOKI_GITHUB_PR", "false") == "true",
3003
3025
  "labels_filter": os.environ.get("LOKI_GITHUB_LABELS", ""),
3004
3026
  "milestone_filter": os.environ.get("LOKI_GITHUB_MILESTONE", ""),
3005
- "limit": int(os.environ.get("LOKI_GITHUB_LIMIT", "100")),
3027
+ "limit": _safe_int_env("LOKI_GITHUB_LIMIT", 100),
3006
3028
  "imported_tasks": 0,
3007
3029
  "synced_updates": 0,
3008
3030
  "repo": None,
@@ -3232,7 +3254,7 @@ def _build_metrics_text() -> str:
3232
3254
  lines.append("")
3233
3255
 
3234
3256
  # 3. loki_iteration_max (gauge) -------------------------------------------
3235
- max_iterations = int(os.environ.get("LOKI_MAX_ITERATIONS", "1000"))
3257
+ max_iterations = _safe_int_env("LOKI_MAX_ITERATIONS", 1000)
3236
3258
  lines.append("# HELP loki_iteration_max Maximum configured iterations")
3237
3259
  lines.append("# TYPE loki_iteration_max gauge")
3238
3260
  lines.append(f"loki_iteration_max {max_iterations}")
@@ -3402,6 +3424,132 @@ async def get_prd_observations():
3402
3424
  return PlainTextResponse("Error reading PRD observations.", status_code=500)
3403
3425
 
3404
3426
 
3427
+ # =============================================================================
3428
+ # Checklist Waiver Management Endpoints (Phase 4)
3429
+ # =============================================================================
3430
+
3431
+ @app.get("/api/checklist/waivers")
3432
+ async def get_checklist_waivers():
3433
+ """Get all checklist waivers."""
3434
+ waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
3435
+ if not waivers_file.exists():
3436
+ return {"waivers": []}
3437
+ try:
3438
+ return json.loads(waivers_file.read_text())
3439
+ except (json.JSONDecodeError, IOError):
3440
+ return {"waivers": [], "error": "Failed to read waivers file"}
3441
+
3442
+
3443
+ @app.post("/api/checklist/waivers", dependencies=[Depends(auth.require_scope("control"))])
3444
+ async def add_checklist_waiver(request: Request):
3445
+ """Add a waiver for a checklist item."""
3446
+ if not _control_limiter.check("control"):
3447
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
3448
+ try:
3449
+ body = await request.json()
3450
+ except Exception:
3451
+ return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
3452
+
3453
+ item_id = body.get("item_id")
3454
+ reason = body.get("reason")
3455
+ if not item_id or not reason:
3456
+ return JSONResponse(status_code=400, content={"error": "item_id and reason required"})
3457
+
3458
+ if not isinstance(reason, str) or len(reason) > 1024:
3459
+ return JSONResponse(status_code=400, content={"error": "reason must be a string (max 1024 chars)"})
3460
+
3461
+ # Sanitize item_id: non-empty, max 256 chars, no path traversal
3462
+ if not isinstance(item_id, str) or len(item_id) > 256 or ".." in item_id or "/" in item_id or "\\" in item_id:
3463
+ return JSONResponse(status_code=400, content={"error": "Invalid item_id: must be 1-256 chars, no path traversal characters"})
3464
+
3465
+ waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
3466
+
3467
+ # Load existing
3468
+ waivers = {"waivers": []}
3469
+ if waivers_file.exists():
3470
+ try:
3471
+ waivers = json.loads(waivers_file.read_text())
3472
+ except (json.JSONDecodeError, IOError):
3473
+ pass
3474
+
3475
+ # Check duplicate
3476
+ for w in waivers.get("waivers", []):
3477
+ if w.get("item_id") == item_id and w.get("active", True):
3478
+ return JSONResponse(status_code=409, content={"status": "already_exists", "item_id": item_id})
3479
+
3480
+ # Add waiver
3481
+ waiver = {
3482
+ "item_id": item_id,
3483
+ "reason": reason,
3484
+ "waived_by": body.get("waived_by", "dashboard"),
3485
+ "waived_at": datetime.now(timezone.utc).isoformat(),
3486
+ "active": True
3487
+ }
3488
+ waivers.setdefault("waivers", []).append(waiver)
3489
+
3490
+ # Ensure directory exists
3491
+ waivers_file.parent.mkdir(parents=True, exist_ok=True)
3492
+
3493
+ # Atomic write
3494
+ tmp_file = waivers_file.with_suffix(".tmp")
3495
+ tmp_file.write_text(json.dumps(waivers, indent=2))
3496
+ tmp_file.replace(waivers_file)
3497
+
3498
+ return {"status": "added", "waiver": waiver}
3499
+
3500
+
3501
+ @app.delete("/api/checklist/waivers/{item_id}", dependencies=[Depends(auth.require_scope("control"))])
3502
+ async def remove_checklist_waiver(item_id: str):
3503
+ """Deactivate a waiver for a checklist item."""
3504
+ if not _control_limiter.check("control"):
3505
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
3506
+
3507
+ # Sanitize item_id: non-empty, max 256 chars, no path traversal
3508
+ if not item_id or len(item_id) > 256 or ".." in item_id or "/" in item_id or "\\" in item_id:
3509
+ raise HTTPException(status_code=400, detail="Invalid item_id: must be 1-256 chars, no path traversal characters")
3510
+
3511
+ waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
3512
+ if not waivers_file.exists():
3513
+ return JSONResponse(status_code=404, content={"error": "No waivers file"})
3514
+
3515
+ try:
3516
+ waivers = json.loads(waivers_file.read_text())
3517
+ except (json.JSONDecodeError, IOError):
3518
+ return JSONResponse(status_code=500, content={"error": "Failed to read waivers"})
3519
+
3520
+ found = False
3521
+ for w in waivers.get("waivers", []):
3522
+ if w.get("item_id") == item_id and w.get("active", True):
3523
+ w["active"] = False
3524
+ found = True
3525
+
3526
+ if not found:
3527
+ return JSONResponse(status_code=404, content={"error": f"No active waiver for {item_id}"})
3528
+
3529
+ # Atomic write
3530
+ tmp_file = waivers_file.with_suffix(".tmp")
3531
+ tmp_file.write_text(json.dumps(waivers, indent=2))
3532
+ tmp_file.replace(waivers_file)
3533
+
3534
+ return {"status": "removed", "item_id": item_id}
3535
+
3536
+
3537
+ # =============================================================================
3538
+ # Council Hard Gate Endpoint (Phase 4)
3539
+ # =============================================================================
3540
+
3541
+ @app.get("/api/council/gate")
3542
+ async def get_council_gate():
3543
+ """Get council hard gate status."""
3544
+ gate_file = _get_loki_dir() / "council" / "gate-block.json"
3545
+ if not gate_file.exists():
3546
+ return {"blocked": False}
3547
+ try:
3548
+ return json.loads(gate_file.read_text())
3549
+ except (json.JSONDecodeError, IOError):
3550
+ return {"blocked": False, "error": "Failed to read gate file"}
3551
+
3552
+
3405
3553
  # =============================================================================
3406
3554
  # App Runner Endpoints (v5.45.0)
3407
3555
  # =============================================================================
@@ -3436,7 +3584,7 @@ async def get_app_runner_logs(lines: int = Query(default=100, ge=1, le=1000)):
3436
3584
  @app.post("/api/control/app-restart", dependencies=[Depends(auth.require_scope("control"))])
3437
3585
  async def control_app_restart(request: Request):
3438
3586
  """Signal app runner to restart the application."""
3439
- if not _control_limiter.check(str(request.client.host)):
3587
+ if not _control_limiter.check(request.client.host if request.client else "unknown"):
3440
3588
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
3441
3589
  loki_dir = _get_loki_dir()
3442
3590
  signal_dir = loki_dir / "app-runner"
@@ -3449,7 +3597,7 @@ async def control_app_restart(request: Request):
3449
3597
  @app.post("/api/control/app-stop", dependencies=[Depends(auth.require_scope("control"))])
3450
3598
  async def control_app_stop(request: Request):
3451
3599
  """Signal app runner to stop the application."""
3452
- if not _control_limiter.check(str(request.client.host)):
3600
+ if not _control_limiter.check(request.client.host if request.client else "unknown"):
3453
3601
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
3454
3602
  loki_dir = _get_loki_dir()
3455
3603
  signal_dir = loki_dir / "app-runner"
@@ -3487,7 +3635,7 @@ async def get_playwright_screenshot():
3487
3635
  screenshots = sorted(screenshots_dir.glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True)
3488
3636
  if not screenshots:
3489
3637
  return {"screenshot": None}
3490
- return {"screenshot": str(screenshots[0])}
3638
+ return FileResponse(str(screenshots[0]), media_type="image/png")
3491
3639
 
3492
3640
 
3493
3641
  # =============================================================================
@@ -3594,7 +3742,7 @@ def run_server(host: str = None, port: int = None) -> None:
3594
3742
  # Default to localhost-only for security
3595
3743
  host = os.environ.get("LOKI_DASHBOARD_HOST", "127.0.0.1")
3596
3744
  if port is None:
3597
- port = int(os.environ.get("LOKI_DASHBOARD_PORT", "57374"))
3745
+ port = _safe_int_env("LOKI_DASHBOARD_PORT", 57374)
3598
3746
 
3599
3747
  uvicorn_kwargs = {
3600
3748
  "host": host,