loki-mode 5.35.0 → 5.36.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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/run.sh +18 -1
- package/autonomy/sandbox.sh +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/auth.py +18 -5
- package/dashboard/requirements.txt +7 -7
- package/dashboard/server.py +51 -14
- package/docs/INSTALLATION.md +1 -1
- package/package.json +1 -1
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.
|
|
6
|
+
# Loki Mode v5.36.0
|
|
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.
|
|
263
|
+
**v5.36.0 | security hardening: auth wiring, salted hashing, non-root Docker, shell injection fix | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.
|
|
1
|
+
5.36.0
|
package/autonomy/run.sh
CHANGED
|
@@ -3333,7 +3333,24 @@ check_staged_autonomy() {
|
|
|
3333
3333
|
}
|
|
3334
3334
|
|
|
3335
3335
|
check_command_allowed() {
|
|
3336
|
-
# Check if a command
|
|
3336
|
+
# Check if a command string contains any blocked patterns from BLOCKED_COMMANDS.
|
|
3337
|
+
#
|
|
3338
|
+
# SECURITY NOTE: This function is intentionally NOT called by run.sh because
|
|
3339
|
+
# run.sh does not directly execute arbitrary shell commands from user or agent
|
|
3340
|
+
# input. Command execution is handled by the AI CLI's own permission model:
|
|
3341
|
+
# - Claude Code: --dangerously-skip-permissions (with its own allowlist)
|
|
3342
|
+
# - Codex CLI: --full-auto or exec --dangerously-bypass-approvals-and-sandbox
|
|
3343
|
+
# - Gemini CLI: --approval-mode=yolo
|
|
3344
|
+
#
|
|
3345
|
+
# HUMAN_INPUT.md content is injected as a text prompt to the AI agent (not
|
|
3346
|
+
# executed as a shell command), and is already guarded by:
|
|
3347
|
+
# - LOKI_PROMPT_INJECTION=false by default (disabled unless explicitly enabled)
|
|
3348
|
+
# - Symlink rejection (prevents path traversal attacks)
|
|
3349
|
+
# - 1MB file size limit
|
|
3350
|
+
#
|
|
3351
|
+
# This function is retained as a utility for external callers (sandbox.sh,
|
|
3352
|
+
# custom hooks, or user scripts) that may need to validate commands against
|
|
3353
|
+
# the BLOCKED_COMMANDS list before execution.
|
|
3337
3354
|
local command="$1"
|
|
3338
3355
|
|
|
3339
3356
|
IFS=',' read -ra BLOCKED_ARRAY <<< "$BLOCKED_COMMANDS"
|
package/autonomy/sandbox.sh
CHANGED
|
@@ -727,7 +727,7 @@ docker_desktop_sandbox_prompt() {
|
|
|
727
727
|
docker sandbox exec -w "$PROJECT_DIR" \
|
|
728
728
|
${DESKTOP_ENV_ARGS[@]+"${DESKTOP_ENV_ARGS[@]}"} \
|
|
729
729
|
"$DESKTOP_SANDBOX_NAME" \
|
|
730
|
-
bash -c "
|
|
730
|
+
bash -c "printf '%s\n' \"\$1\" > ${PROJECT_DIR}/.loki/HUMAN_INPUT.md" -- "$message"
|
|
731
731
|
|
|
732
732
|
log_success "Prompt sent to sandbox"
|
|
733
733
|
}
|
package/dashboard/__init__.py
CHANGED
package/dashboard/auth.py
CHANGED
|
@@ -53,9 +53,20 @@ def _save_tokens(tokens: dict) -> None:
|
|
|
53
53
|
json.dump(tokens, f, indent=2, default=str)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def _hash_token(token: str) -> str:
|
|
57
|
-
"""Hash a token for storage.
|
|
58
|
-
|
|
56
|
+
def _hash_token(token: str, salt: str = None) -> tuple[str, str]:
|
|
57
|
+
"""Hash a token for storage with a per-token random salt.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
token: The raw token string to hash.
|
|
61
|
+
salt: Optional salt. If None, a new random salt is generated.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Tuple of (hex_digest, salt).
|
|
65
|
+
"""
|
|
66
|
+
if salt is None:
|
|
67
|
+
salt = secrets.token_hex(16)
|
|
68
|
+
digest = hashlib.sha256((salt + token).encode()).hexdigest()
|
|
69
|
+
return digest, salt
|
|
59
70
|
|
|
60
71
|
|
|
61
72
|
def _constant_time_compare(a: str, b: str) -> bool:
|
|
@@ -94,7 +105,7 @@ def generate_token(
|
|
|
94
105
|
|
|
95
106
|
# Generate secure random token
|
|
96
107
|
raw_token = f"loki_{secrets.token_urlsafe(32)}"
|
|
97
|
-
token_hash = _hash_token(raw_token)
|
|
108
|
+
token_hash, token_salt = _hash_token(raw_token)
|
|
98
109
|
token_id = token_hash[:12]
|
|
99
110
|
|
|
100
111
|
tokens = _load_tokens()
|
|
@@ -114,6 +125,7 @@ def generate_token(
|
|
|
114
125
|
"id": token_id,
|
|
115
126
|
"name": name,
|
|
116
127
|
"hash": token_hash,
|
|
128
|
+
"salt": token_salt,
|
|
117
129
|
"scopes": scopes or ["*"],
|
|
118
130
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
119
131
|
"expires_at": expires_at,
|
|
@@ -229,11 +241,12 @@ def validate_token(raw_token: str) -> Optional[dict]:
|
|
|
229
241
|
if not raw_token or not raw_token.startswith("loki_"):
|
|
230
242
|
return None
|
|
231
243
|
|
|
232
|
-
token_hash = _hash_token(raw_token)
|
|
233
244
|
tokens = _load_tokens()
|
|
234
245
|
|
|
235
246
|
# Find matching token (using constant-time comparison to prevent timing attacks)
|
|
236
247
|
for token in tokens["tokens"].values():
|
|
248
|
+
stored_salt = token.get("salt", "")
|
|
249
|
+
token_hash, _ = _hash_token(raw_token, salt=stored_salt)
|
|
237
250
|
if _constant_time_compare(token["hash"], token_hash):
|
|
238
251
|
# Check if revoked
|
|
239
252
|
if token.get("revoked"):
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Loki Mode Dashboard Dependencies
|
|
2
|
-
fastapi
|
|
3
|
-
uvicorn[standard]
|
|
4
|
-
sqlalchemy
|
|
5
|
-
aiosqlite
|
|
6
|
-
greenlet
|
|
7
|
-
pydantic
|
|
8
|
-
websockets
|
|
2
|
+
fastapi==0.115.6
|
|
3
|
+
uvicorn[standard]==0.34.0
|
|
4
|
+
sqlalchemy==2.0.36
|
|
5
|
+
aiosqlite==0.20.0
|
|
6
|
+
greenlet==3.1.1
|
|
7
|
+
pydantic==2.10.4
|
|
8
|
+
websockets==14.1
|
package/dashboard/server.py
CHANGED
|
@@ -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
|
|
@@ -43,6 +45,29 @@ from . import registry
|
|
|
43
45
|
from . import auth
|
|
44
46
|
from . import audit
|
|
45
47
|
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Simple in-memory rate limiter for control endpoints
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
class _RateLimiter:
|
|
53
|
+
"""Simple in-memory rate limiter for control endpoints."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, max_calls: int = 10, window_seconds: int = 60):
|
|
56
|
+
self._max_calls = max_calls
|
|
57
|
+
self._window = window_seconds
|
|
58
|
+
self._calls: dict[str, list[float]] = defaultdict(list)
|
|
59
|
+
|
|
60
|
+
def check(self, key: str) -> bool:
|
|
61
|
+
now = time.time()
|
|
62
|
+
self._calls[key] = [t for t in self._calls[key] if now - t < self._window]
|
|
63
|
+
if len(self._calls[key]) >= self._max_calls:
|
|
64
|
+
return False
|
|
65
|
+
self._calls[key].append(now)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_control_limiter = _RateLimiter(max_calls=10, window_seconds=60)
|
|
70
|
+
|
|
46
71
|
# Set up logging
|
|
47
72
|
logger = logging.getLogger(__name__)
|
|
48
73
|
|
|
@@ -440,7 +465,7 @@ async def get_project(
|
|
|
440
465
|
)
|
|
441
466
|
|
|
442
467
|
|
|
443
|
-
@app.put("/api/projects/{project_id}", response_model=ProjectResponse)
|
|
468
|
+
@app.put("/api/projects/{project_id}", response_model=ProjectResponse, dependencies=[Depends(auth.require_scope("control"))])
|
|
444
469
|
async def update_project(
|
|
445
470
|
project_id: int,
|
|
446
471
|
project_update: ProjectUpdate,
|
|
@@ -486,7 +511,7 @@ async def update_project(
|
|
|
486
511
|
)
|
|
487
512
|
|
|
488
513
|
|
|
489
|
-
@app.delete("/api/projects/{project_id}", status_code=204)
|
|
514
|
+
@app.delete("/api/projects/{project_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
490
515
|
async def delete_project(
|
|
491
516
|
project_id: int,
|
|
492
517
|
db: AsyncSession = Depends(get_db),
|
|
@@ -662,7 +687,7 @@ async def get_task(
|
|
|
662
687
|
return TaskResponse.model_validate(task)
|
|
663
688
|
|
|
664
689
|
|
|
665
|
-
@app.put("/api/tasks/{task_id}", response_model=TaskResponse)
|
|
690
|
+
@app.put("/api/tasks/{task_id}", response_model=TaskResponse, dependencies=[Depends(auth.require_scope("control"))])
|
|
666
691
|
async def update_task(
|
|
667
692
|
task_id: int,
|
|
668
693
|
task_update: TaskUpdate,
|
|
@@ -703,7 +728,7 @@ async def update_task(
|
|
|
703
728
|
return TaskResponse.model_validate(task)
|
|
704
729
|
|
|
705
730
|
|
|
706
|
-
@app.delete("/api/tasks/{task_id}", status_code=204)
|
|
731
|
+
@app.delete("/api/tasks/{task_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
707
732
|
async def delete_task(
|
|
708
733
|
task_id: int,
|
|
709
734
|
db: AsyncSession = Depends(get_db),
|
|
@@ -890,7 +915,7 @@ async def get_registered_project(identifier: str):
|
|
|
890
915
|
return project
|
|
891
916
|
|
|
892
917
|
|
|
893
|
-
@app.delete("/api/registry/projects/{identifier}", status_code=204)
|
|
918
|
+
@app.delete("/api/registry/projects/{identifier}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
894
919
|
async def unregister_project(identifier: str):
|
|
895
920
|
"""Remove a project from the registry."""
|
|
896
921
|
if not registry.unregister_project(identifier):
|
|
@@ -1028,7 +1053,7 @@ async def list_tokens(include_revoked: bool = False):
|
|
|
1028
1053
|
return auth.list_tokens(include_revoked=include_revoked)
|
|
1029
1054
|
|
|
1030
1055
|
|
|
1031
|
-
@app.delete("/api/enterprise/tokens/{identifier}")
|
|
1056
|
+
@app.delete("/api/enterprise/tokens/{identifier}", dependencies=[Depends(auth.require_scope("admin"))])
|
|
1032
1057
|
async def revoke_token(identifier: str, permanent: bool = False):
|
|
1033
1058
|
"""
|
|
1034
1059
|
Revoke or delete a token (enterprise only).
|
|
@@ -1606,18 +1631,22 @@ def _read_events(time_range: str = "7d") -> list:
|
|
|
1606
1631
|
|
|
1607
1632
|
|
|
1608
1633
|
# Session control endpoints (proxy to control.py functions)
|
|
1609
|
-
@app.post("/api/control/pause")
|
|
1634
|
+
@app.post("/api/control/pause", dependencies=[Depends(auth.require_scope("control"))])
|
|
1610
1635
|
async def pause_session():
|
|
1611
1636
|
"""Pause the current session by creating PAUSE file."""
|
|
1637
|
+
if not _control_limiter.check("control"):
|
|
1638
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1612
1639
|
pause_file = _get_loki_dir() / "PAUSE"
|
|
1613
1640
|
pause_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1614
1641
|
pause_file.write_text(datetime.now(timezone.utc).isoformat())
|
|
1615
1642
|
return {"success": True, "message": "Session paused"}
|
|
1616
1643
|
|
|
1617
1644
|
|
|
1618
|
-
@app.post("/api/control/resume")
|
|
1645
|
+
@app.post("/api/control/resume", dependencies=[Depends(auth.require_scope("control"))])
|
|
1619
1646
|
async def resume_session():
|
|
1620
1647
|
"""Resume a paused session by removing PAUSE/STOP files."""
|
|
1648
|
+
if not _control_limiter.check("control"):
|
|
1649
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1621
1650
|
for fname in ["PAUSE", "STOP"]:
|
|
1622
1651
|
fpath = _get_loki_dir() / fname
|
|
1623
1652
|
try:
|
|
@@ -1627,9 +1656,11 @@ async def resume_session():
|
|
|
1627
1656
|
return {"success": True, "message": "Session resumed"}
|
|
1628
1657
|
|
|
1629
1658
|
|
|
1630
|
-
@app.post("/api/control/stop")
|
|
1659
|
+
@app.post("/api/control/stop", dependencies=[Depends(auth.require_scope("control"))])
|
|
1631
1660
|
async def stop_session():
|
|
1632
1661
|
"""Stop the session by creating STOP file and sending SIGTERM."""
|
|
1662
|
+
if not _control_limiter.check("control"):
|
|
1663
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1633
1664
|
stop_file = _get_loki_dir() / "STOP"
|
|
1634
1665
|
stop_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1635
1666
|
stop_file.write_text(datetime.now(timezone.utc).isoformat())
|
|
@@ -2132,7 +2163,7 @@ async def create_checkpoint(body: CheckpointCreate = None):
|
|
|
2132
2163
|
# =============================================================================
|
|
2133
2164
|
|
|
2134
2165
|
@app.get("/api/agents")
|
|
2135
|
-
async def get_agents():
|
|
2166
|
+
async def get_agents(token: Optional[dict] = Depends(auth.get_current_token)):
|
|
2136
2167
|
"""Get all active and recent agents."""
|
|
2137
2168
|
agents_file = _get_loki_dir() / "state" / "agents.json"
|
|
2138
2169
|
agents = []
|
|
@@ -2187,9 +2218,11 @@ async def get_agents():
|
|
|
2187
2218
|
return agents
|
|
2188
2219
|
|
|
2189
2220
|
|
|
2190
|
-
@app.post("/api/agents/{agent_id}/kill")
|
|
2221
|
+
@app.post("/api/agents/{agent_id}/kill", dependencies=[Depends(auth.require_scope("control"))])
|
|
2191
2222
|
async def kill_agent(agent_id: str):
|
|
2192
2223
|
"""Kill a specific agent by ID."""
|
|
2224
|
+
if not _control_limiter.check("control"):
|
|
2225
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2193
2226
|
agent_id = _sanitize_agent_id(agent_id)
|
|
2194
2227
|
agents_file = _get_loki_dir() / "state" / "agents.json"
|
|
2195
2228
|
if not agents_file.exists():
|
|
@@ -2234,9 +2267,11 @@ async def kill_agent(agent_id: str):
|
|
|
2234
2267
|
)
|
|
2235
2268
|
|
|
2236
2269
|
|
|
2237
|
-
@app.post("/api/agents/{agent_id}/pause")
|
|
2270
|
+
@app.post("/api/agents/{agent_id}/pause", dependencies=[Depends(auth.require_scope("control"))])
|
|
2238
2271
|
async def pause_agent(agent_id: str):
|
|
2239
2272
|
"""Pause a specific agent by writing a pause signal."""
|
|
2273
|
+
if not _control_limiter.check("control"):
|
|
2274
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2240
2275
|
agent_id = _sanitize_agent_id(agent_id)
|
|
2241
2276
|
signal_dir = _get_loki_dir() / "signals"
|
|
2242
2277
|
signal_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -2246,9 +2281,11 @@ async def pause_agent(agent_id: str):
|
|
|
2246
2281
|
return {"success": True, "message": f"Pause signal sent to agent {agent_id}"}
|
|
2247
2282
|
|
|
2248
2283
|
|
|
2249
|
-
@app.post("/api/agents/{agent_id}/resume")
|
|
2284
|
+
@app.post("/api/agents/{agent_id}/resume", dependencies=[Depends(auth.require_scope("control"))])
|
|
2250
2285
|
async def resume_agent(agent_id: str):
|
|
2251
2286
|
"""Resume a paused agent."""
|
|
2287
|
+
if not _control_limiter.check("control"):
|
|
2288
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2252
2289
|
agent_id = _sanitize_agent_id(agent_id)
|
|
2253
2290
|
signal_file = _get_loki_dir() / "signals" / f"PAUSE_AGENT_{agent_id}"
|
|
2254
2291
|
try:
|
|
@@ -2259,7 +2296,7 @@ async def resume_agent(agent_id: str):
|
|
|
2259
2296
|
|
|
2260
2297
|
|
|
2261
2298
|
@app.get("/api/logs")
|
|
2262
|
-
async def get_logs(lines: int = 100):
|
|
2299
|
+
async def get_logs(lines: int = 100, token: Optional[dict] = Depends(auth.get_current_token)):
|
|
2263
2300
|
"""Get recent log entries from session log files."""
|
|
2264
2301
|
log_dir = _get_loki_dir() / "logs"
|
|
2265
2302
|
entries = []
|
package/docs/INSTALLATION.md
CHANGED