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 +2 -2
- package/VERSION +1 -1
- package/autonomy/sandbox.sh +2 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +68 -0
- package/dashboard/auth.py +84 -5
- package/dashboard/server.py +37 -2
- package/docs/INSTALLATION.md +1 -1
- package/package.json +1 -1
- package/providers/gemini.sh +4 -2
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.
|
|
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.
|
|
263
|
+
**v5.37.1 | security: WebSocket auth, RBAC roles, syslog forwarding, sandbox hardening | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.37.
|
|
1
|
+
5.37.1
|
package/autonomy/sandbox.sh
CHANGED
|
@@ -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
|
-
|
|
824
|
-
|
|
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"
|
package/dashboard/__init__.py
CHANGED
package/dashboard/audit.py
CHANGED
|
@@ -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
|
|
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":
|
|
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
|
|
375
|
+
True if token has the scope (directly or via hierarchy)
|
|
308
376
|
"""
|
|
309
377
|
scopes = token_info.get("scopes", [])
|
|
310
|
-
|
|
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
|
# ---------------------------------------------------------------------------
|
package/dashboard/server.py
CHANGED
|
@@ -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
|
-
|
|
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
|
package/docs/INSTALLATION.md
CHANGED
package/package.json
CHANGED
package/providers/gemini.sh
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|