loki-mode 5.36.0 → 5.37.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.
@@ -21,6 +21,7 @@ from fastapi import (
21
21
  FastAPI,
22
22
  HTTPException,
23
23
  Query,
24
+ Request,
24
25
  WebSocket,
25
26
  WebSocketDisconnect,
26
27
  )
@@ -44,6 +45,14 @@ from .models import (
44
45
  from . import registry
45
46
  from . import auth
46
47
  from . import audit
48
+ from . import secrets as secrets_mod
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # TLS Configuration (optional - disabled by default)
52
+ # Set both LOKI_TLS_CERT and LOKI_TLS_KEY to enable HTTPS
53
+ # ---------------------------------------------------------------------------
54
+ LOKI_TLS_CERT = os.environ.get("LOKI_TLS_CERT", "") # Path to PEM certificate
55
+ LOKI_TLS_KEY = os.environ.get("LOKI_TLS_KEY", "") # Path to PEM private key
47
56
 
48
57
  # ---------------------------------------------------------------------------
49
58
  # Simple in-memory rate limiter for control endpoints
@@ -514,6 +523,7 @@ async def update_project(
514
523
  @app.delete("/api/projects/{project_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
515
524
  async def delete_project(
516
525
  project_id: int,
526
+ request: Request,
517
527
  db: AsyncSession = Depends(get_db),
518
528
  ) -> None:
519
529
  """Delete a project."""
@@ -525,6 +535,14 @@ async def delete_project(
525
535
  if not project:
526
536
  raise HTTPException(status_code=404, detail="Project not found")
527
537
 
538
+ audit.log_event(
539
+ action="delete",
540
+ resource_type="project",
541
+ resource_id=str(project_id),
542
+ details={"name": project.name},
543
+ ip_address=request.client.host if request.client else None,
544
+ )
545
+
528
546
  await db.delete(project)
529
547
 
530
548
  # Broadcast update
@@ -731,6 +749,7 @@ async def update_task(
731
749
  @app.delete("/api/tasks/{task_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
732
750
  async def delete_task(
733
751
  task_id: int,
752
+ request: Request,
734
753
  db: AsyncSession = Depends(get_db),
735
754
  ) -> None:
736
755
  """Delete a task."""
@@ -743,6 +762,15 @@ async def delete_task(
743
762
  raise HTTPException(status_code=404, detail="Task not found")
744
763
 
745
764
  project_id = task.project_id
765
+
766
+ audit.log_event(
767
+ action="delete",
768
+ resource_type="task",
769
+ resource_id=str(task_id),
770
+ details={"project_id": project_id, "title": task.title},
771
+ ip_address=request.client.host if request.client else None,
772
+ )
773
+
746
774
  await db.delete(task)
747
775
 
748
776
  # Broadcast update
@@ -916,11 +944,18 @@ async def get_registered_project(identifier: str):
916
944
 
917
945
 
918
946
  @app.delete("/api/registry/projects/{identifier}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
919
- async def unregister_project(identifier: str):
947
+ async def unregister_project(identifier: str, request: Request):
920
948
  """Remove a project from the registry."""
921
949
  if not registry.unregister_project(identifier):
922
950
  raise HTTPException(status_code=404, detail="Project not found in registry")
923
951
 
952
+ audit.log_event(
953
+ action="delete",
954
+ resource_type="registry_project",
955
+ resource_id=identifier,
956
+ ip_address=request.client.host if request.client else None,
957
+ )
958
+
924
959
 
925
960
  @app.get("/api/registry/projects/{identifier}/health", response_model=HealthResponse)
926
961
  async def get_project_health(identifier: str):
@@ -983,8 +1018,24 @@ async def get_enterprise_status():
983
1018
  """Check which enterprise features are enabled."""
984
1019
  return {
985
1020
  "auth_enabled": auth.is_enterprise_mode(),
1021
+ "oidc_enabled": auth.is_oidc_mode(),
986
1022
  "audit_enabled": audit.is_audit_enabled(),
987
- "enterprise_mode": auth.is_enterprise_mode() or audit.is_audit_enabled(),
1023
+ "enterprise_mode": auth.is_enterprise_mode() or auth.is_oidc_mode() or audit.is_audit_enabled(),
1024
+ }
1025
+
1026
+
1027
+ @app.get("/api/auth/info")
1028
+ async def get_auth_info():
1029
+ """Get authentication configuration info (public endpoint).
1030
+
1031
+ Returns which auth methods are available so clients can determine
1032
+ how to authenticate (token-based, OIDC/SSO, or anonymous).
1033
+ """
1034
+ return {
1035
+ "token_auth_enabled": auth.ENTERPRISE_AUTH_ENABLED,
1036
+ "oidc_enabled": auth.OIDC_ENABLED,
1037
+ "oidc_issuer": auth.OIDC_ISSUER if auth.OIDC_ENABLED else None,
1038
+ "oidc_client_id": auth.OIDC_CLIENT_ID if auth.OIDC_ENABLED else None,
988
1039
  }
989
1040
 
990
1041
 
@@ -1088,7 +1139,7 @@ async def revoke_token(identifier: str, permanent: bool = False):
1088
1139
  return {"status": "ok", "action": action, "identifier": identifier}
1089
1140
 
1090
1141
 
1091
- # Audit log endpoints (only active when LOKI_ENTERPRISE_AUDIT=true)
1142
+ # Audit log endpoints (enabled by default, disable with LOKI_AUDIT_DISABLED=true)
1092
1143
  class AuditQueryParams(BaseModel):
1093
1144
  """Query parameters for audit logs."""
1094
1145
  start_date: Optional[str] = None
@@ -1120,7 +1171,7 @@ async def query_audit_logs(
1120
1171
  if not audit.is_audit_enabled():
1121
1172
  raise HTTPException(
1122
1173
  status_code=403,
1123
- detail="Enterprise audit logging not enabled. Set LOKI_ENTERPRISE_AUDIT=true"
1174
+ detail="Audit logging is disabled. Remove LOKI_AUDIT_DISABLED or set LOKI_ENTERPRISE_AUDIT=true"
1124
1175
  )
1125
1176
 
1126
1177
  return audit.query_logs(
@@ -1136,11 +1187,11 @@ async def query_audit_logs(
1136
1187
 
1137
1188
  @app.get("/api/enterprise/audit/summary")
1138
1189
  async def get_audit_summary(days: int = 7):
1139
- """Get audit activity summary (enterprise only)."""
1190
+ """Get audit activity summary."""
1140
1191
  if not audit.is_audit_enabled():
1141
1192
  raise HTTPException(
1142
1193
  status_code=403,
1143
- detail="Enterprise audit logging not enabled"
1194
+ detail="Audit logging is disabled. Remove LOKI_AUDIT_DISABLED or set LOKI_ENTERPRISE_AUDIT=true"
1144
1195
  )
1145
1196
 
1146
1197
  return audit.get_audit_summary(days=days)
@@ -1657,10 +1708,18 @@ async def resume_session():
1657
1708
 
1658
1709
 
1659
1710
  @app.post("/api/control/stop", dependencies=[Depends(auth.require_scope("control"))])
1660
- async def stop_session():
1711
+ async def stop_session(request: Request):
1661
1712
  """Stop the session by creating STOP file and sending SIGTERM."""
1662
1713
  if not _control_limiter.check("control"):
1663
1714
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
1715
+
1716
+ audit.log_event(
1717
+ action="stop",
1718
+ resource_type="session",
1719
+ details={"source": "api"},
1720
+ ip_address=request.client.host if request.client else None,
1721
+ )
1722
+
1664
1723
  stop_file = _get_loki_dir() / "STOP"
1665
1724
  stop_file.parent.mkdir(parents=True, exist_ok=True)
1666
1725
  stop_file.write_text(datetime.now(timezone.utc).isoformat())
@@ -1842,6 +1901,62 @@ async def get_cost():
1842
1901
  }
1843
1902
 
1844
1903
 
1904
+ @app.get("/api/budget")
1905
+ async def get_budget():
1906
+ """Get current budget status from .loki/metrics/budget.json and cost data."""
1907
+ loki_dir = _get_loki_dir()
1908
+ budget_file = loki_dir / "metrics" / "budget.json"
1909
+ signals_dir = loki_dir / "signals"
1910
+
1911
+ # Read budget configuration
1912
+ budget_limit = None
1913
+ budget_used = 0.0
1914
+ exceeded = False
1915
+ exceeded_at = None
1916
+
1917
+ if budget_file.exists():
1918
+ try:
1919
+ budget_data = json.loads(budget_file.read_text())
1920
+ budget_limit = budget_data.get("limit") or budget_data.get("budget_limit")
1921
+ budget_used = budget_data.get("budget_used", 0.0)
1922
+ exceeded = budget_data.get("exceeded", False)
1923
+ exceeded_at = budget_data.get("exceeded_at")
1924
+ except (json.JSONDecodeError, KeyError):
1925
+ pass
1926
+
1927
+ # Also check env var for limit if not in file
1928
+ if budget_limit is None:
1929
+ env_limit = os.environ.get("LOKI_BUDGET_LIMIT", "")
1930
+ if env_limit:
1931
+ try:
1932
+ budget_limit = float(env_limit)
1933
+ except ValueError:
1934
+ pass
1935
+
1936
+ # Check for budget exceeded signal
1937
+ signal_file = signals_dir / "BUDGET_EXCEEDED"
1938
+ if signal_file.exists():
1939
+ exceeded = True
1940
+ if exceeded_at is None:
1941
+ try:
1942
+ sig_data = json.loads(signal_file.read_text())
1943
+ exceeded_at = sig_data.get("timestamp")
1944
+ except (json.JSONDecodeError, KeyError):
1945
+ pass
1946
+
1947
+ remaining = None
1948
+ if budget_limit is not None:
1949
+ remaining = max(0.0, float(budget_limit) - float(budget_used))
1950
+
1951
+ return {
1952
+ "budget_limit": float(budget_limit) if budget_limit is not None else None,
1953
+ "current_cost": round(float(budget_used), 4),
1954
+ "exceeded": exceeded,
1955
+ "exceeded_at": exceeded_at,
1956
+ "remaining": round(remaining, 4) if remaining is not None else None,
1957
+ }
1958
+
1959
+
1845
1960
  # =============================================================================
1846
1961
  # Pricing API
1847
1962
  # =============================================================================
@@ -2219,11 +2334,19 @@ async def get_agents(token: Optional[dict] = Depends(auth.get_current_token)):
2219
2334
 
2220
2335
 
2221
2336
  @app.post("/api/agents/{agent_id}/kill", dependencies=[Depends(auth.require_scope("control"))])
2222
- async def kill_agent(agent_id: str):
2337
+ async def kill_agent(agent_id: str, request: Request):
2223
2338
  """Kill a specific agent by ID."""
2224
2339
  if not _control_limiter.check("control"):
2225
2340
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
2226
2341
  agent_id = _sanitize_agent_id(agent_id)
2342
+
2343
+ audit.log_event(
2344
+ action="kill",
2345
+ resource_type="agent",
2346
+ resource_id=agent_id,
2347
+ details={"source": "api"},
2348
+ ip_address=request.client.host if request.client else None,
2349
+ )
2227
2350
  agents_file = _get_loki_dir() / "state" / "agents.json"
2228
2351
  if not agents_file.exists():
2229
2352
  raise HTTPException(404, "No agents file found")
@@ -2377,6 +2500,90 @@ except ImportError as e:
2377
2500
  logger.debug(f"Collaboration module not available: {e}")
2378
2501
 
2379
2502
 
2503
+ # =============================================================================
2504
+ # Secrets / Credential Status
2505
+ # =============================================================================
2506
+
2507
+ @app.get("/api/secrets/status", dependencies=[Depends(auth.require_scope("admin"))])
2508
+ async def get_secrets_status():
2509
+ """Get API key status (masked, validation, source). Admin only."""
2510
+ result = secrets_mod.load_secrets()
2511
+ rotated = secrets_mod.check_rotation(
2512
+ str(_get_loki_dir() / "state" / "key-fingerprints.json")
2513
+ )
2514
+ return {
2515
+ "keys": result,
2516
+ "rotated_since_last_check": rotated,
2517
+ }
2518
+
2519
+
2520
+ # =============================================================================
2521
+ # Process Health / Watchdog API
2522
+ # =============================================================================
2523
+
2524
+
2525
+ @app.get("/api/health/processes")
2526
+ async def get_process_health(token: Optional[dict] = Depends(auth.get_current_token)):
2527
+ """Get health status of all loki processes (dashboard, session, agents)."""
2528
+ result: dict[str, Any] = {"dashboard": None, "session": None, "agents": []}
2529
+
2530
+ loki_dir = _get_loki_dir()
2531
+
2532
+ # Dashboard PID
2533
+ dpid_file = loki_dir / "dashboard" / "dashboard.pid"
2534
+ if dpid_file.exists():
2535
+ try:
2536
+ dpid = int(dpid_file.read_text().strip())
2537
+ try:
2538
+ os.kill(dpid, 0)
2539
+ result["dashboard"] = {"pid": dpid, "status": "alive"}
2540
+ except OSError:
2541
+ result["dashboard"] = {"pid": dpid, "status": "dead"}
2542
+ except (ValueError, OSError):
2543
+ pass
2544
+
2545
+ # Session PID
2546
+ spid_file = loki_dir / "loki.pid"
2547
+ if spid_file.exists():
2548
+ try:
2549
+ spid = int(spid_file.read_text().strip())
2550
+ try:
2551
+ os.kill(spid, 0)
2552
+ result["session"] = {"pid": spid, "status": "alive"}
2553
+ except OSError:
2554
+ result["session"] = {"pid": spid, "status": "dead"}
2555
+ except (ValueError, OSError):
2556
+ pass
2557
+
2558
+ # Agent PIDs
2559
+ agents_file = loki_dir / "state" / "agents.json"
2560
+ if agents_file.exists():
2561
+ try:
2562
+ agents = json.loads(agents_file.read_text())
2563
+ for agent in agents:
2564
+ pid = agent.get("pid")
2565
+ status = "unknown"
2566
+ if pid:
2567
+ try:
2568
+ os.kill(int(pid), 0)
2569
+ status = "alive"
2570
+ except (OSError, ValueError):
2571
+ status = "dead"
2572
+ result["agents"].append({
2573
+ "id": agent.get("id", ""),
2574
+ "name": agent.get("name", ""),
2575
+ "pid": pid,
2576
+ "status": status,
2577
+ })
2578
+ except Exception:
2579
+ pass
2580
+
2581
+ watchdog_enabled = os.environ.get("LOKI_WATCHDOG", "false").lower() == "true"
2582
+ result["watchdog_enabled"] = watchdog_enabled
2583
+
2584
+ return result
2585
+
2586
+
2380
2587
  # =============================================================================
2381
2588
  # Static File Serving (Production/Docker)
2382
2589
  # =============================================================================
@@ -2482,7 +2689,20 @@ def run_server(host: str = None, port: int = None) -> None:
2482
2689
  host = os.environ.get("LOKI_DASHBOARD_HOST", "127.0.0.1")
2483
2690
  if port is None:
2484
2691
  port = int(os.environ.get("LOKI_DASHBOARD_PORT", "57374"))
2485
- uvicorn.run(app, host=host, port=port)
2692
+
2693
+ uvicorn_kwargs = {
2694
+ "host": host,
2695
+ "port": port,
2696
+ "log_level": "info",
2697
+ }
2698
+
2699
+ # Enable TLS if both cert and key are provided
2700
+ if LOKI_TLS_CERT and LOKI_TLS_KEY:
2701
+ uvicorn_kwargs["ssl_certfile"] = LOKI_TLS_CERT
2702
+ uvicorn_kwargs["ssl_keyfile"] = LOKI_TLS_KEY
2703
+ logger.info("TLS enabled: cert=%s key=%s", LOKI_TLS_CERT, LOKI_TLS_KEY)
2704
+
2705
+ uvicorn.run(app, **uvicorn_kwargs)
2486
2706
 
2487
2707
 
2488
2708
  if __name__ == "__main__":
@@ -2,7 +2,7 @@
2
2
 
3
3
  Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v5.36.0
5
+ **Version:** v5.37.0
6
6
 
7
7
  ---
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "5.36.0",
3
+ "version": "5.37.0",
4
4
  "description": "Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "claude",