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 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.35.0
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.35.0 | checkpoint/restore, GitHub Action provider-agnostic | ~260 lines core**
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.35.0
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 is in the blocked list
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"
@@ -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 "echo '$message' > ${PROJECT_DIR}/.loki/HUMAN_INPUT.md"
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
  }
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "5.35.0"
10
+ __version__ = "5.36.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
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
- return hashlib.sha256(token.encode()).hexdigest()
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>=0.109.0
3
- uvicorn[standard]>=0.27.0
4
- sqlalchemy>=2.0.0
5
- aiosqlite>=0.19.0
6
- greenlet>=3.0.0
7
- pydantic>=2.0.0
8
- websockets>=12.0
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
@@ -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 = []
@@ -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.36.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.36.0",
4
4
  "description": "Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "claude",