loki-mode 5.35.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.
@@ -8,6 +8,8 @@ import asyncio
8
8
  import json
9
9
  import logging
10
10
  import os
11
+ import time
12
+ from collections import defaultdict
11
13
  from contextlib import asynccontextmanager
12
14
  from datetime import datetime, timedelta, timezone
13
15
  from pathlib import Path as _Path
@@ -19,6 +21,7 @@ from fastapi import (
19
21
  FastAPI,
20
22
  HTTPException,
21
23
  Query,
24
+ Request,
22
25
  WebSocket,
23
26
  WebSocketDisconnect,
24
27
  )
@@ -42,6 +45,37 @@ from .models import (
42
45
  from . import registry
43
46
  from . import auth
44
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
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Simple in-memory rate limiter for control endpoints
59
+ # ---------------------------------------------------------------------------
60
+
61
+ class _RateLimiter:
62
+ """Simple in-memory rate limiter for control endpoints."""
63
+
64
+ def __init__(self, max_calls: int = 10, window_seconds: int = 60):
65
+ self._max_calls = max_calls
66
+ self._window = window_seconds
67
+ self._calls: dict[str, list[float]] = defaultdict(list)
68
+
69
+ def check(self, key: str) -> bool:
70
+ now = time.time()
71
+ self._calls[key] = [t for t in self._calls[key] if now - t < self._window]
72
+ if len(self._calls[key]) >= self._max_calls:
73
+ return False
74
+ self._calls[key].append(now)
75
+ return True
76
+
77
+
78
+ _control_limiter = _RateLimiter(max_calls=10, window_seconds=60)
45
79
 
46
80
  # Set up logging
47
81
  logger = logging.getLogger(__name__)
@@ -440,7 +474,7 @@ async def get_project(
440
474
  )
441
475
 
442
476
 
443
- @app.put("/api/projects/{project_id}", response_model=ProjectResponse)
477
+ @app.put("/api/projects/{project_id}", response_model=ProjectResponse, dependencies=[Depends(auth.require_scope("control"))])
444
478
  async def update_project(
445
479
  project_id: int,
446
480
  project_update: ProjectUpdate,
@@ -486,9 +520,10 @@ async def update_project(
486
520
  )
487
521
 
488
522
 
489
- @app.delete("/api/projects/{project_id}", status_code=204)
523
+ @app.delete("/api/projects/{project_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
490
524
  async def delete_project(
491
525
  project_id: int,
526
+ request: Request,
492
527
  db: AsyncSession = Depends(get_db),
493
528
  ) -> None:
494
529
  """Delete a project."""
@@ -500,6 +535,14 @@ async def delete_project(
500
535
  if not project:
501
536
  raise HTTPException(status_code=404, detail="Project not found")
502
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
+
503
546
  await db.delete(project)
504
547
 
505
548
  # Broadcast update
@@ -662,7 +705,7 @@ async def get_task(
662
705
  return TaskResponse.model_validate(task)
663
706
 
664
707
 
665
- @app.put("/api/tasks/{task_id}", response_model=TaskResponse)
708
+ @app.put("/api/tasks/{task_id}", response_model=TaskResponse, dependencies=[Depends(auth.require_scope("control"))])
666
709
  async def update_task(
667
710
  task_id: int,
668
711
  task_update: TaskUpdate,
@@ -703,9 +746,10 @@ async def update_task(
703
746
  return TaskResponse.model_validate(task)
704
747
 
705
748
 
706
- @app.delete("/api/tasks/{task_id}", status_code=204)
749
+ @app.delete("/api/tasks/{task_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
707
750
  async def delete_task(
708
751
  task_id: int,
752
+ request: Request,
709
753
  db: AsyncSession = Depends(get_db),
710
754
  ) -> None:
711
755
  """Delete a task."""
@@ -718,6 +762,15 @@ async def delete_task(
718
762
  raise HTTPException(status_code=404, detail="Task not found")
719
763
 
720
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
+
721
774
  await db.delete(task)
722
775
 
723
776
  # Broadcast update
@@ -890,12 +943,19 @@ async def get_registered_project(identifier: str):
890
943
  return project
891
944
 
892
945
 
893
- @app.delete("/api/registry/projects/{identifier}", status_code=204)
894
- async def unregister_project(identifier: str):
946
+ @app.delete("/api/registry/projects/{identifier}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
947
+ async def unregister_project(identifier: str, request: Request):
895
948
  """Remove a project from the registry."""
896
949
  if not registry.unregister_project(identifier):
897
950
  raise HTTPException(status_code=404, detail="Project not found in registry")
898
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
+
899
959
 
900
960
  @app.get("/api/registry/projects/{identifier}/health", response_model=HealthResponse)
901
961
  async def get_project_health(identifier: str):
@@ -958,8 +1018,24 @@ async def get_enterprise_status():
958
1018
  """Check which enterprise features are enabled."""
959
1019
  return {
960
1020
  "auth_enabled": auth.is_enterprise_mode(),
1021
+ "oidc_enabled": auth.is_oidc_mode(),
961
1022
  "audit_enabled": audit.is_audit_enabled(),
962
- "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,
963
1039
  }
964
1040
 
965
1041
 
@@ -1028,7 +1104,7 @@ async def list_tokens(include_revoked: bool = False):
1028
1104
  return auth.list_tokens(include_revoked=include_revoked)
1029
1105
 
1030
1106
 
1031
- @app.delete("/api/enterprise/tokens/{identifier}")
1107
+ @app.delete("/api/enterprise/tokens/{identifier}", dependencies=[Depends(auth.require_scope("admin"))])
1032
1108
  async def revoke_token(identifier: str, permanent: bool = False):
1033
1109
  """
1034
1110
  Revoke or delete a token (enterprise only).
@@ -1063,7 +1139,7 @@ async def revoke_token(identifier: str, permanent: bool = False):
1063
1139
  return {"status": "ok", "action": action, "identifier": identifier}
1064
1140
 
1065
1141
 
1066
- # Audit log endpoints (only active when LOKI_ENTERPRISE_AUDIT=true)
1142
+ # Audit log endpoints (enabled by default, disable with LOKI_AUDIT_DISABLED=true)
1067
1143
  class AuditQueryParams(BaseModel):
1068
1144
  """Query parameters for audit logs."""
1069
1145
  start_date: Optional[str] = None
@@ -1095,7 +1171,7 @@ async def query_audit_logs(
1095
1171
  if not audit.is_audit_enabled():
1096
1172
  raise HTTPException(
1097
1173
  status_code=403,
1098
- 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"
1099
1175
  )
1100
1176
 
1101
1177
  return audit.query_logs(
@@ -1111,11 +1187,11 @@ async def query_audit_logs(
1111
1187
 
1112
1188
  @app.get("/api/enterprise/audit/summary")
1113
1189
  async def get_audit_summary(days: int = 7):
1114
- """Get audit activity summary (enterprise only)."""
1190
+ """Get audit activity summary."""
1115
1191
  if not audit.is_audit_enabled():
1116
1192
  raise HTTPException(
1117
1193
  status_code=403,
1118
- detail="Enterprise audit logging not enabled"
1194
+ detail="Audit logging is disabled. Remove LOKI_AUDIT_DISABLED or set LOKI_ENTERPRISE_AUDIT=true"
1119
1195
  )
1120
1196
 
1121
1197
  return audit.get_audit_summary(days=days)
@@ -1606,18 +1682,22 @@ def _read_events(time_range: str = "7d") -> list:
1606
1682
 
1607
1683
 
1608
1684
  # Session control endpoints (proxy to control.py functions)
1609
- @app.post("/api/control/pause")
1685
+ @app.post("/api/control/pause", dependencies=[Depends(auth.require_scope("control"))])
1610
1686
  async def pause_session():
1611
1687
  """Pause the current session by creating PAUSE file."""
1688
+ if not _control_limiter.check("control"):
1689
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
1612
1690
  pause_file = _get_loki_dir() / "PAUSE"
1613
1691
  pause_file.parent.mkdir(parents=True, exist_ok=True)
1614
1692
  pause_file.write_text(datetime.now(timezone.utc).isoformat())
1615
1693
  return {"success": True, "message": "Session paused"}
1616
1694
 
1617
1695
 
1618
- @app.post("/api/control/resume")
1696
+ @app.post("/api/control/resume", dependencies=[Depends(auth.require_scope("control"))])
1619
1697
  async def resume_session():
1620
1698
  """Resume a paused session by removing PAUSE/STOP files."""
1699
+ if not _control_limiter.check("control"):
1700
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
1621
1701
  for fname in ["PAUSE", "STOP"]:
1622
1702
  fpath = _get_loki_dir() / fname
1623
1703
  try:
@@ -1627,9 +1707,19 @@ async def resume_session():
1627
1707
  return {"success": True, "message": "Session resumed"}
1628
1708
 
1629
1709
 
1630
- @app.post("/api/control/stop")
1631
- async def stop_session():
1710
+ @app.post("/api/control/stop", dependencies=[Depends(auth.require_scope("control"))])
1711
+ async def stop_session(request: Request):
1632
1712
  """Stop the session by creating STOP file and sending SIGTERM."""
1713
+ if not _control_limiter.check("control"):
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
+
1633
1723
  stop_file = _get_loki_dir() / "STOP"
1634
1724
  stop_file.parent.mkdir(parents=True, exist_ok=True)
1635
1725
  stop_file.write_text(datetime.now(timezone.utc).isoformat())
@@ -1811,6 +1901,62 @@ async def get_cost():
1811
1901
  }
1812
1902
 
1813
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
+
1814
1960
  # =============================================================================
1815
1961
  # Pricing API
1816
1962
  # =============================================================================
@@ -2132,7 +2278,7 @@ async def create_checkpoint(body: CheckpointCreate = None):
2132
2278
  # =============================================================================
2133
2279
 
2134
2280
  @app.get("/api/agents")
2135
- async def get_agents():
2281
+ async def get_agents(token: Optional[dict] = Depends(auth.get_current_token)):
2136
2282
  """Get all active and recent agents."""
2137
2283
  agents_file = _get_loki_dir() / "state" / "agents.json"
2138
2284
  agents = []
@@ -2187,10 +2333,20 @@ async def get_agents():
2187
2333
  return agents
2188
2334
 
2189
2335
 
2190
- @app.post("/api/agents/{agent_id}/kill")
2191
- async def kill_agent(agent_id: str):
2336
+ @app.post("/api/agents/{agent_id}/kill", dependencies=[Depends(auth.require_scope("control"))])
2337
+ async def kill_agent(agent_id: str, request: Request):
2192
2338
  """Kill a specific agent by ID."""
2339
+ if not _control_limiter.check("control"):
2340
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
2193
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
+ )
2194
2350
  agents_file = _get_loki_dir() / "state" / "agents.json"
2195
2351
  if not agents_file.exists():
2196
2352
  raise HTTPException(404, "No agents file found")
@@ -2234,9 +2390,11 @@ async def kill_agent(agent_id: str):
2234
2390
  )
2235
2391
 
2236
2392
 
2237
- @app.post("/api/agents/{agent_id}/pause")
2393
+ @app.post("/api/agents/{agent_id}/pause", dependencies=[Depends(auth.require_scope("control"))])
2238
2394
  async def pause_agent(agent_id: str):
2239
2395
  """Pause a specific agent by writing a pause signal."""
2396
+ if not _control_limiter.check("control"):
2397
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
2240
2398
  agent_id = _sanitize_agent_id(agent_id)
2241
2399
  signal_dir = _get_loki_dir() / "signals"
2242
2400
  signal_dir.mkdir(parents=True, exist_ok=True)
@@ -2246,9 +2404,11 @@ async def pause_agent(agent_id: str):
2246
2404
  return {"success": True, "message": f"Pause signal sent to agent {agent_id}"}
2247
2405
 
2248
2406
 
2249
- @app.post("/api/agents/{agent_id}/resume")
2407
+ @app.post("/api/agents/{agent_id}/resume", dependencies=[Depends(auth.require_scope("control"))])
2250
2408
  async def resume_agent(agent_id: str):
2251
2409
  """Resume a paused agent."""
2410
+ if not _control_limiter.check("control"):
2411
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
2252
2412
  agent_id = _sanitize_agent_id(agent_id)
2253
2413
  signal_file = _get_loki_dir() / "signals" / f"PAUSE_AGENT_{agent_id}"
2254
2414
  try:
@@ -2259,7 +2419,7 @@ async def resume_agent(agent_id: str):
2259
2419
 
2260
2420
 
2261
2421
  @app.get("/api/logs")
2262
- async def get_logs(lines: int = 100):
2422
+ async def get_logs(lines: int = 100, token: Optional[dict] = Depends(auth.get_current_token)):
2263
2423
  """Get recent log entries from session log files."""
2264
2424
  log_dir = _get_loki_dir() / "logs"
2265
2425
  entries = []
@@ -2340,6 +2500,90 @@ except ImportError as e:
2340
2500
  logger.debug(f"Collaboration module not available: {e}")
2341
2501
 
2342
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
+
2343
2587
  # =============================================================================
2344
2588
  # Static File Serving (Production/Docker)
2345
2589
  # =============================================================================
@@ -2445,7 +2689,20 @@ def run_server(host: str = None, port: int = None) -> None:
2445
2689
  host = os.environ.get("LOKI_DASHBOARD_HOST", "127.0.0.1")
2446
2690
  if port is None:
2447
2691
  port = int(os.environ.get("LOKI_DASHBOARD_PORT", "57374"))
2448
- 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)
2449
2706
 
2450
2707
 
2451
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.35.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.35.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",