loki-mode 6.37.1 → 6.37.3

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.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v6.37.1
6
+ # Loki Mode v6.37.3
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
267
267
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
268
268
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
269
269
 
270
- **v6.37.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
270
+ **v6.37.3 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.37.1
1
+ 6.37.3
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.37.1"
10
+ __version__ = "6.37.3"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
package/dashboard/auth.py CHANGED
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import base64
16
16
  import hashlib
17
+ import hmac
17
18
  import json
18
19
  import os
19
20
  import secrets
@@ -103,6 +104,9 @@ def _save_tokens(tokens: dict) -> None:
103
104
  TOKEN_FILE.touch(mode=0o600, exist_ok=True)
104
105
  with open(TOKEN_FILE, "w") as f:
105
106
  json.dump(tokens, f, indent=2, default=str)
107
+ # Enforce 0600 on every write, not just creation -- touch(mode=) only
108
+ # applies when the file is new, so an external chmod would persist.
109
+ os.chmod(TOKEN_FILE, 0o600)
106
110
 
107
111
 
108
112
  def _hash_token(token: str, salt: str = None) -> tuple[str, str]:
@@ -123,7 +127,7 @@ def _hash_token(token: str, salt: str = None) -> tuple[str, str]:
123
127
 
124
128
  def _constant_time_compare(a: str, b: str) -> bool:
125
129
  """Constant-time string comparison to prevent timing attacks."""
126
- return secrets.compare_digest(a.encode(), b.encode())
130
+ return hmac.compare_digest(a.encode(), b.encode())
127
131
 
128
132
 
129
133
  def resolve_scopes(role_or_scopes) -> list[str]:
@@ -342,30 +346,35 @@ def validate_token(raw_token: str) -> Optional[dict]:
342
346
 
343
347
  tokens = _load_tokens()
344
348
 
345
- # Find matching token (using constant-time comparison to prevent timing attacks)
349
+ # Iterate ALL tokens to prevent timing side-channel that leaks token count.
350
+ # Do not short-circuit on match -- always hash and compare every entry.
351
+ matched_token: Optional[dict] = None
346
352
  for token in tokens["tokens"].values():
347
353
  stored_salt = token.get("salt", "")
348
354
  token_hash, _ = _hash_token(raw_token, salt=stored_salt)
349
355
  if _constant_time_compare(token["hash"], token_hash):
350
- # Check if revoked
351
- if token.get("revoked"):
356
+ matched_token = token
357
+
358
+ if matched_token is not None:
359
+ # Check if revoked
360
+ if matched_token.get("revoked"):
361
+ return None
362
+
363
+ # Check expiration
364
+ if matched_token.get("expires_at"):
365
+ expires = datetime.fromisoformat(matched_token["expires_at"])
366
+ if datetime.now(timezone.utc) > expires:
352
367
  return None
353
368
 
354
- # Check expiration
355
- if token.get("expires_at"):
356
- expires = datetime.fromisoformat(token["expires_at"])
357
- if datetime.now(timezone.utc) > expires:
358
- return None
359
-
360
- # Update last used
361
- token["last_used"] = datetime.now(timezone.utc).isoformat()
362
- _save_tokens(tokens)
363
-
364
- return {
365
- "id": token["id"],
366
- "name": token["name"],
367
- "scopes": token["scopes"],
368
- }
369
+ # Update last used
370
+ matched_token["last_used"] = datetime.now(timezone.utc).isoformat()
371
+ _save_tokens(tokens)
372
+
373
+ return {
374
+ "id": matched_token["id"],
375
+ "name": matched_token["name"],
376
+ "scopes": matched_token["scopes"],
377
+ }
369
378
 
370
379
  return None
371
380
 
@@ -1284,20 +1284,23 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
1284
1284
  "data": {"message": "Connected to Loki Dashboard"},
1285
1285
  })
1286
1286
 
1287
- # Keep connection alive and handle incoming messages
1287
+ # Keep connection alive and handle incoming messages.
1288
+ # Close idle connections after ~60s of no response to pings.
1289
+ missed_pongs = 0
1288
1290
  while True:
1289
1291
  try:
1290
1292
  data = await asyncio.wait_for(
1291
1293
  websocket.receive_text(),
1292
- timeout=30.0 # Ping every 30 seconds
1294
+ timeout=30.0 # Ping every 30 seconds of silence
1293
1295
  )
1294
- # Handle incoming messages (e.g., subscriptions)
1296
+ missed_pongs = 0 # any message resets idle counter
1295
1297
  try:
1296
1298
  message = json.loads(data)
1297
1299
  if message.get("type") == "ping":
1298
1300
  await manager.send_personal(websocket, {"type": "pong"})
1301
+ elif message.get("type") == "pong":
1302
+ pass # client responded to our ping
1299
1303
  elif message.get("type") == "subscribe":
1300
- # Could implement channel subscriptions here
1301
1304
  await manager.send_personal(websocket, {
1302
1305
  "type": "subscribed",
1303
1306
  "data": message.get("data", {}),
@@ -1305,8 +1308,15 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
1305
1308
  except json.JSONDecodeError as e:
1306
1309
  logger.debug(f"WebSocket received invalid JSON: {e}")
1307
1310
  except asyncio.TimeoutError:
1308
- # Send keepalive ping
1309
- await manager.send_personal(websocket, {"type": "ping"})
1311
+ missed_pongs += 1
1312
+ if missed_pongs >= 2:
1313
+ # Two consecutive pings with no reply -- close idle connection
1314
+ logger.info("Closing idle WebSocket (no pong response)")
1315
+ break
1316
+ try:
1317
+ await manager.send_personal(websocket, {"type": "ping"})
1318
+ except Exception:
1319
+ break
1310
1320
 
1311
1321
  except WebSocketDisconnect:
1312
1322
  manager.disconnect(websocket)
@@ -1608,7 +1618,7 @@ class AuditQueryParams(BaseModel):
1608
1618
  offset: int = 0
1609
1619
 
1610
1620
 
1611
- @app.get("/api/enterprise/audit")
1621
+ @app.get("/api/enterprise/audit", dependencies=[Depends(auth.require_scope("audit"))])
1612
1622
  async def query_audit_logs(
1613
1623
  start_date: Optional[str] = None,
1614
1624
  end_date: Optional[str] = None,
@@ -1640,7 +1650,7 @@ async def query_audit_logs(
1640
1650
  )
1641
1651
 
1642
1652
 
1643
- @app.get("/api/enterprise/audit/summary")
1653
+ @app.get("/api/enterprise/audit/summary", dependencies=[Depends(auth.require_scope("audit"))])
1644
1654
  async def get_audit_summary(days: int = 7):
1645
1655
  """Get audit activity summary."""
1646
1656
  if not audit.is_audit_enabled():