loki-mode 5.37.0 → 5.37.1

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 zero human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v5.37.0
6
+ # Loki Mode v5.37.1
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -260,4 +260,4 @@ The following features are documented in skill modules but not yet fully automat
260
260
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
261
261
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
262
262
 
263
- **v5.37.0 | enterprise: TLS, OIDC/SSO, audit default-on, budget controls, watchdog, secrets, OpenClaw | ~260 lines core**
263
+ **v5.37.1 | security: WebSocket auth, RBAC roles, syslog forwarding, sandbox hardening | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 5.37.0
1
+ 5.37.1
@@ -820,8 +820,8 @@ start_sandbox() {
820
820
  "--security-opt=no-new-privileges:true"
821
821
  "--cap-drop=ALL"
822
822
  "--cap-add=CHOWN"
823
- "--cap-add=SETUID"
824
- "--cap-add=SETGID"
823
+ # SETUID/SETGID intentionally omitted: container runs as non-root (UID 1000)
824
+ # and should not be able to change UID/GID, which would undermine isolation
825
825
 
826
826
  # Network
827
827
  "--network=$SANDBOX_NETWORK"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "5.37.0"
10
+ __version__ = "5.37.1"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -5,10 +5,19 @@ Enabled by default. Disable with LOKI_AUDIT_DISABLED=true environment variable.
5
5
  Legacy env var LOKI_ENTERPRISE_AUDIT=true always enables audit (backward compat).
6
6
 
7
7
  Audit logs: ~/.loki/dashboard/audit/
8
+
9
+ Syslog forwarding (optional):
10
+ Set LOKI_AUDIT_SYSLOG_HOST to enable forwarding to a centralized syslog server.
11
+ LOKI_AUDIT_SYSLOG_PORT defaults to 514.
12
+ LOKI_AUDIT_SYSLOG_PROTO defaults to "udp" (also supports "tcp").
8
13
  """
9
14
 
10
15
  import json
16
+ import logging
17
+ import logging.handlers
11
18
  import os
19
+ import socket
20
+ import sys
12
21
  from datetime import datetime, timezone
13
22
  from pathlib import Path
14
23
  from typing import Any, Optional
@@ -25,6 +34,38 @@ AUDIT_DIR = Path.home() / ".loki" / "dashboard" / "audit"
25
34
  MAX_LOG_SIZE_MB = int(os.environ.get("LOKI_AUDIT_MAX_SIZE_MB", "10"))
26
35
  MAX_LOG_FILES = int(os.environ.get("LOKI_AUDIT_MAX_FILES", "10"))
27
36
 
37
+ # Syslog forwarding (optional, off by default)
38
+ _SYSLOG_HOST = os.environ.get("LOKI_AUDIT_SYSLOG_HOST", "").strip()
39
+ _SYSLOG_PORT = int(os.environ.get("LOKI_AUDIT_SYSLOG_PORT", "514"))
40
+ _SYSLOG_PROTO = os.environ.get("LOKI_AUDIT_SYSLOG_PROTO", "udp").lower().strip()
41
+
42
+ # Actions considered security-relevant (logged at WARNING level in syslog)
43
+ _SECURITY_ACTIONS = frozenset({
44
+ "delete", "kill", "stop", "login", "logout",
45
+ "create_token", "revoke_token",
46
+ })
47
+
48
+ _syslog_handler: logging.handlers.SysLogHandler | None = None
49
+ SYSLOG_ENABLED: bool = False
50
+
51
+ if _SYSLOG_HOST:
52
+ try:
53
+ _socktype = socket.SOCK_STREAM if _SYSLOG_PROTO == "tcp" else socket.SOCK_DGRAM
54
+ _syslog_handler = logging.handlers.SysLogHandler(
55
+ address=(_SYSLOG_HOST, _SYSLOG_PORT),
56
+ facility=logging.handlers.SysLogHandler.LOG_LOCAL0,
57
+ socktype=_socktype,
58
+ )
59
+ _syslog_handler.setFormatter(logging.Formatter("loki-audit: %(message)s"))
60
+ SYSLOG_ENABLED = True
61
+ except Exception as _exc:
62
+ print(
63
+ f"[loki-audit] WARNING: Failed to configure syslog handler "
64
+ f"({_SYSLOG_HOST}:{_SYSLOG_PORT}/{_SYSLOG_PROTO}): {_exc}",
65
+ file=sys.stderr,
66
+ )
67
+ _syslog_handler = None
68
+
28
69
 
29
70
  def _ensure_audit_dir() -> None:
30
71
  """Ensure the audit directory exists."""
@@ -68,6 +109,30 @@ def _cleanup_old_logs() -> None:
68
109
  oldest.unlink()
69
110
 
70
111
 
112
+ def _forward_to_syslog(entry: dict) -> None:
113
+ """Forward an audit entry to syslog if configured. Fire-and-forget."""
114
+ if _syslog_handler is None:
115
+ return
116
+ try:
117
+ message = json.dumps(entry, separators=(",", ":"))
118
+ action = entry.get("action", "")
119
+ is_security = action in _SECURITY_ACTIONS or not entry.get("success", True)
120
+ level = logging.WARNING if is_security else logging.INFO
121
+ record = logging.LogRecord(
122
+ name="loki-audit",
123
+ level=level,
124
+ pathname="",
125
+ lineno=0,
126
+ msg=message,
127
+ args=(),
128
+ exc_info=None,
129
+ )
130
+ _syslog_handler.emit(record)
131
+ except Exception:
132
+ # Fire-and-forget: never block the main audit write path
133
+ pass
134
+
135
+
71
136
  def log_event(
72
137
  action: str,
73
138
  resource_type: str,
@@ -121,6 +186,9 @@ def log_event(
121
186
  with open(log_file, "a") as f:
122
187
  f.write(json.dumps(entry) + "\n")
123
188
 
189
+ # Forward to syslog if configured
190
+ _forward_to_syslog(entry)
191
+
124
192
  return entry
125
193
 
126
194
 
package/dashboard/auth.py CHANGED
@@ -35,6 +35,23 @@ OIDC_CLIENT_ID = os.environ.get("LOKI_OIDC_CLIENT_ID", "")
35
35
  OIDC_AUDIENCE = os.environ.get("LOKI_OIDC_AUDIENCE", "") # Usually same as client_id
36
36
  OIDC_ENABLED = bool(OIDC_ISSUER and OIDC_CLIENT_ID)
37
37
 
38
+ # Role-to-scope mapping (predefined roles)
39
+ ROLES = {
40
+ "admin": ["*"], # Full access
41
+ "operator": ["control", "read", "write"], # Start/stop/pause, view+edit dashboard
42
+ "viewer": ["read"], # Read-only dashboard access
43
+ "auditor": ["read", "audit"], # Read dashboard + audit logs
44
+ }
45
+
46
+ # Scope hierarchy: higher scopes implicitly grant lower ones (single-level lookup).
47
+ # * -> control -> write -> read
48
+ # Each scope explicitly lists ALL scopes it grants (no transitive resolution).
49
+ _SCOPE_HIERARCHY = {
50
+ "*": {"control", "write", "read", "audit", "admin"},
51
+ "control": {"write", "read"},
52
+ "write": {"read"},
53
+ }
54
+
38
55
  if OIDC_ENABLED:
39
56
  import logging as _logging
40
57
  _logging.getLogger("loki.auth").warning(
@@ -98,10 +115,39 @@ def _constant_time_compare(a: str, b: str) -> bool:
98
115
  return secrets.compare_digest(a.encode(), b.encode())
99
116
 
100
117
 
118
+ def resolve_scopes(role_or_scopes) -> list[str]:
119
+ """Resolve a role name or scope list into a concrete list of scopes.
120
+
121
+ Args:
122
+ role_or_scopes: Either a role name (str), a single scope (str),
123
+ or a list of scopes.
124
+
125
+ Returns:
126
+ List of scope strings.
127
+ """
128
+ if isinstance(role_or_scopes, list):
129
+ return role_or_scopes
130
+ if isinstance(role_or_scopes, str):
131
+ if role_or_scopes in ROLES:
132
+ return list(ROLES[role_or_scopes])
133
+ return [role_or_scopes]
134
+ return ["*"]
135
+
136
+
137
+ def list_roles() -> dict[str, list[str]]:
138
+ """Return the predefined role-to-scope mapping.
139
+
140
+ Returns:
141
+ Dict mapping role names to their scope lists.
142
+ """
143
+ return dict(ROLES)
144
+
145
+
101
146
  def generate_token(
102
147
  name: str,
103
148
  scopes: Optional[list[str]] = None,
104
149
  expires_days: Optional[int] = None,
150
+ role: Optional[str] = None,
105
151
  ) -> dict:
106
152
  """
107
153
  Generate a new API token.
@@ -110,12 +156,16 @@ def generate_token(
110
156
  name: Human-readable name for the token
111
157
  scopes: Optional list of permission scopes (default: all)
112
158
  expires_days: Optional expiration in days (None = never expires)
159
+ role: Optional role name (admin, operator, viewer, auditor).
160
+ If provided, scopes are resolved from the role.
161
+ Cannot be combined with explicit scopes.
113
162
 
114
163
  Returns:
115
164
  Dict with token info (includes raw token - only shown once)
116
165
 
117
166
  Raises:
118
- ValueError: If name is empty/too long or expires_days is invalid
167
+ ValueError: If name is empty/too long, expires_days is invalid,
168
+ or role is unrecognized
119
169
  """
120
170
  # Validate inputs
121
171
  if not name or not name.strip():
@@ -124,9 +174,19 @@ def generate_token(
124
174
  raise ValueError("Token name too long (max 255 characters)")
125
175
  if expires_days is not None and expires_days <= 0:
126
176
  raise ValueError("expires_days must be positive (or None for no expiration)")
177
+ if role is not None and role not in ROLES:
178
+ raise ValueError(
179
+ f"Unknown role '{role}'. Valid roles: {', '.join(ROLES.keys())}"
180
+ )
127
181
 
128
182
  name = name.strip()
129
183
 
184
+ # Resolve scopes: role takes precedence if provided
185
+ if role is not None:
186
+ resolved_scopes = resolve_scopes(role)
187
+ else:
188
+ resolved_scopes = scopes
189
+
130
190
  # Generate secure random token
131
191
  raw_token = f"loki_{secrets.token_urlsafe(32)}"
132
192
  token_hash, token_salt = _hash_token(raw_token)
@@ -150,12 +210,14 @@ def generate_token(
150
210
  "name": name,
151
211
  "hash": token_hash,
152
212
  "salt": token_salt,
153
- "scopes": scopes or ["*"],
213
+ "scopes": resolved_scopes or ["*"],
154
214
  "created_at": datetime.now(timezone.utc).isoformat(),
155
215
  "expires_at": expires_at,
156
216
  "last_used": None,
157
217
  "revoked": False,
158
218
  }
219
+ if role is not None:
220
+ token_entry["role"] = role
159
221
 
160
222
  tokens["tokens"][token_id] = token_entry
161
223
  _save_tokens(tokens)
@@ -247,6 +309,8 @@ def list_tokens(include_revoked: bool = False) -> list[dict]:
247
309
  "last_used": token.get("last_used"),
248
310
  "revoked": token.get("revoked", False),
249
311
  }
312
+ if "role" in token:
313
+ safe_token["role"] = token["role"]
250
314
  result.append(safe_token)
251
315
 
252
316
  return result
@@ -297,17 +361,32 @@ def validate_token(raw_token: str) -> Optional[dict]:
297
361
 
298
362
  def has_scope(token_info: dict, required_scope: str) -> bool:
299
363
  """
300
- Check if a token has a required scope.
364
+ Check if a token has a required scope, respecting scope hierarchy.
365
+
366
+ Hierarchy (higher scopes implicitly grant lower ones):
367
+ * -> control -> write -> read
368
+ * also grants audit, admin, and all other scopes
301
369
 
302
370
  Args:
303
371
  token_info: Token metadata from validate_token
304
372
  required_scope: The scope to check
305
373
 
306
374
  Returns:
307
- True if token has the scope (or wildcard)
375
+ True if token has the scope (directly or via hierarchy)
308
376
  """
309
377
  scopes = token_info.get("scopes", [])
310
- return "*" in scopes or required_scope in scopes
378
+
379
+ # Direct match
380
+ if required_scope in scopes:
381
+ return True
382
+
383
+ # Check hierarchy: does any held scope implicitly grant the required one?
384
+ for scope in scopes:
385
+ implied = _SCOPE_HIERARCHY.get(scope, set())
386
+ if required_scope in implied:
387
+ return True
388
+
389
+ return False
311
390
 
312
391
 
313
392
  # ---------------------------------------------------------------------------
@@ -246,7 +246,13 @@ app = FastAPI(
246
246
  # Add CORS middleware - restricted to localhost by default.
247
247
  # Set LOKI_DASHBOARD_CORS to override (comma-separated origins).
248
248
  _cors_default = "http://localhost:57374,http://127.0.0.1:57374"
249
- _cors_origins = os.environ.get("LOKI_DASHBOARD_CORS", _cors_default).split(",")
249
+ _cors_raw = os.environ.get("LOKI_DASHBOARD_CORS", _cors_default)
250
+ if _cors_raw.strip() == "*":
251
+ logger.warning(
252
+ "LOKI_DASHBOARD_CORS is set to '*' -- all origins are allowed. "
253
+ "This is insecure for production deployments."
254
+ )
255
+ _cors_origins = _cors_raw.split(",")
250
256
  app.add_middleware(
251
257
  CORSMiddleware,
252
258
  allow_origins=[o.strip() for o in _cors_origins if o.strip()],
@@ -827,7 +833,36 @@ async def move_task(
827
833
  # WebSocket endpoint
828
834
  @app.websocket("/ws")
829
835
  async def websocket_endpoint(websocket: WebSocket) -> None:
830
- """WebSocket endpoint for real-time updates."""
836
+ """WebSocket endpoint for real-time updates.
837
+
838
+ When enterprise auth or OIDC is enabled, a valid token must be passed
839
+ as a query parameter: ``/ws?token=loki_xxx`` (or a JWT for OIDC).
840
+ Browsers cannot send Authorization headers on WebSocket upgrade
841
+ requests, so query-parameter auth is the standard approach.
842
+ """
843
+ # --- WebSocket authentication gate ---
844
+ # NOTE: Query-parameter auth is used because browsers cannot send
845
+ # Authorization headers on WS upgrade. Tokens may appear in reverse
846
+ # proxy access logs -- configure log sanitization for /ws in production.
847
+ # FastAPI Depends() is not supported on @app.websocket() routes.
848
+ if auth.is_enterprise_mode() or auth.is_oidc_mode():
849
+ ws_token: Optional[str] = websocket.query_params.get("token")
850
+ if not ws_token:
851
+ await websocket.close(code=1008) # Policy Violation
852
+ return
853
+
854
+ token_info: Optional[dict] = None
855
+ # Try OIDC first for JWT-style tokens
856
+ if auth.is_oidc_mode() and not ws_token.startswith("loki_"):
857
+ token_info = auth.validate_oidc_token(ws_token)
858
+ # Fall back to enterprise token auth
859
+ if token_info is None and auth.is_enterprise_mode():
860
+ token_info = auth.validate_token(ws_token)
861
+
862
+ if token_info is None:
863
+ await websocket.close(code=1008) # Policy Violation
864
+ return
865
+
831
866
  await manager.connect(websocket)
832
867
  try:
833
868
  # Send initial connection confirmation
@@ -2,7 +2,7 @@
2
2
 
3
3
  Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v5.37.0
5
+ **Version:** v5.37.1
6
6
 
7
7
  ---
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "5.37.0",
3
+ "version": "5.37.1",
4
4
  "description": "Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "claude",
@@ -49,7 +49,7 @@ PROVIDER_MAX_PARALLEL=1
49
49
 
50
50
  # Model Configuration
51
51
  # Gemini CLI supports --model flag to specify model
52
- # Primary: gemini-3-pro-preview (latest as of Jan 2026)
52
+ # Primary: gemini-3-pro-preview (preview names - may change when GA is released)
53
53
  # Fallback: gemini-3-flash-preview (for rate limit scenarios)
54
54
  PROVIDER_MODEL="gemini-3-pro-preview"
55
55
  PROVIDER_MODEL_FALLBACK="gemini-3-flash-preview"
@@ -69,7 +69,9 @@ PROVIDER_TASK_MODEL_VALUES=()
69
69
  # Context and Limits
70
70
  PROVIDER_CONTEXT_WINDOW=1000000 # Gemini 3 has 1M context
71
71
  PROVIDER_MAX_OUTPUT_TOKENS=65536
72
- PROVIDER_RATE_LIMIT_RPM=60
72
+ # Rate limit varies by tier: Free=5-15 RPM, Tier1=150+ RPM, Tier2=500+ RPM
73
+ # Default to conservative free-tier value; override with LOKI_GEMINI_RPM env var
74
+ PROVIDER_RATE_LIMIT_RPM="${LOKI_GEMINI_RPM:-15}"
73
75
 
74
76
  # Cost (USD per 1K tokens, approximate for Gemini 3 Pro)
75
77
  PROVIDER_COST_INPUT_PLANNING=0.00125