loki-mode 7.76.0 → 7.77.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: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.76.0
6
+ # Loki Mode v7.77.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -406,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
406
406
 
407
407
  ---
408
408
 
409
- **v7.76.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.77.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.76.0
1
+ 7.77.0
@@ -256,7 +256,22 @@ loki_council_dispatch_agents() {
256
256
  # default-off export in claude-flags.sh (sourced above) already covers this;
257
257
  # the inline prefix is belt-and-suspenders, self-documenting, and a no-op when
258
258
  # caveman is absent.
259
- response=$(CAVEMAN_DEFAULT_MODE=off claude --dangerously-skip-permissions \
259
+ #
260
+ # timeout-guard the dispatch (parity with the heuristic council path in
261
+ # completion-council.sh:2074 and :2200, same LOKI_COUNCIL_REVIEW_TIMEOUT:-600
262
+ # knob). The heuristic loop this dispatch replaced wrapped every claude
263
+ # subcall in `timeout`; this helper did not, so a hung `claude --agents`
264
+ # would stall the entire run indefinitely. `timeout` precedes the env-var
265
+ # assignment, so `env` is used to set CAVEMAN_DEFAULT_MODE for the child.
266
+ # On timeout, `timeout` exits 124; that exit is the command-substitution exit
267
+ # (no pipe here), captured into rc via `|| rc=$?`. The existing rc check below
268
+ # then routes any non-zero exit (timeout 124 or any other claude failure) to
269
+ # `return 1` -- the heuristic fallback the caller falls through to
270
+ # (completion-council.sh:2792). Fail-closed: a hung or timed-out council can
271
+ # never become a false COMPLETE; it always degrades to the heuristic path
272
+ # (which has its own timeout + conservative defaults).
273
+ response=$(timeout "${LOKI_COUNCIL_REVIEW_TIMEOUT:-600}" \
274
+ env CAVEMAN_DEFAULT_MODE=off claude --dangerously-skip-permissions \
260
275
  -p "$prompt" \
261
276
  --agents "$agents_json" \
262
277
  --json-schema "$schema_path" 2>"$stderr_log") || rc=$?
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.76.0"
10
+ __version__ = "7.77.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -863,6 +863,111 @@ app.include_router(api_v2_router)
863
863
  # in web-app/server.py for `loki web` (port 57375). One source of truth, no
864
864
  # duplicated UIs. Import is best-effort: if web-app is missing (e.g. partial
865
865
  # install) the dashboard still starts; /lab/* returns 404 with a clear hint.
866
+ class _MountAuthGuard:
867
+ """ASGI wrapper that enforces the dashboard's scope auth at a mount boundary.
868
+
869
+ Starlette does NOT propagate the parent app's route dependencies to a
870
+ mounted sub-app, so without this wrapper the Purple Lab routes (including
871
+ file write/delete and a process-spawn endpoint in web-app/server.py) are
872
+ reachable UNAUTHENTICATED when enterprise auth is enabled. This wrapper
873
+ runs the same token validation as the dashboard's own require_scope("read")
874
+ dependency before delegating to the sub-app.
875
+
876
+ When enterprise auth (and OIDC) are OFF, get_current_token returns None and
877
+ has_scope is never reached: the request passes through unchanged, so local
878
+ default-mode behavior is identical to an unguarded mount.
879
+ """
880
+
881
+ def __init__(self, app, required_scope: str = "read") -> None:
882
+ self._app = app
883
+ self._required_scope = required_scope
884
+
885
+ @staticmethod
886
+ def _validate_ws_token(token_str: "str | None") -> "dict | None":
887
+ # Mirror the HTTP get_current_token order: try OIDC first for a non-loki_
888
+ # token (JWTs do not carry the loki_ prefix), then fall back to loki token
889
+ # auth. This keeps WS auth consistent with the HTTP path so an OIDC-only
890
+ # deployment can authenticate WS clients too (not just loki_ tokens).
891
+ if not token_str:
892
+ return None
893
+ if auth.is_oidc_mode() and not token_str.startswith("loki_"):
894
+ oidc_info = auth.validate_oidc_token(token_str)
895
+ if oidc_info:
896
+ return oidc_info
897
+ if auth.is_enterprise_mode():
898
+ return auth.validate_token(token_str)
899
+ return None
900
+
901
+ @staticmethod
902
+ def _ws_token_from_scope(scope) -> "str | None":
903
+ # A browser WebSocket cannot set an Authorization header, so accept the
904
+ # token either from an Authorization: Bearer header (programmatic clients)
905
+ # or from a ?token= / ?access_token= query parameter (browser clients),
906
+ # matching how scoped WS clients pass credentials elsewhere.
907
+ for raw_name, raw_val in scope.get("headers", []) or []:
908
+ if raw_name == b"authorization":
909
+ val = raw_val.decode("latin-1", "ignore")
910
+ if val.lower().startswith("bearer "):
911
+ return val[7:].strip() or None
912
+ return val.strip() or None
913
+ from urllib.parse import parse_qs
914
+ qs = scope.get("query_string", b"") or b""
915
+ params = parse_qs(qs.decode("latin-1", "ignore"))
916
+ for key in ("token", "access_token"):
917
+ if params.get(key):
918
+ return params[key][0] or None
919
+ return None
920
+
921
+ async def __call__(self, scope, receive, send) -> None:
922
+ if scope["type"] == "lifespan":
923
+ # Lifespan has no client request; nothing to authenticate.
924
+ await self._app(scope, receive, send)
925
+ return
926
+ if scope["type"] == "websocket":
927
+ # The Purple Lab sub-app exposes WebSocket endpoints (a PTY terminal
928
+ # and an HMR proxy). Starlette does not run the parent auth on them,
929
+ # so without this branch /lab/ws/* would be reachable unauthenticated
930
+ # when enterprise auth is on (a PTY login shell with no auth). Validate
931
+ # the same scoped token as the HTTP path; on failure close the
932
+ # handshake with policy-violation 1008 BEFORE delegating to the sub-app.
933
+ if not auth.is_enterprise_mode() and not auth.is_oidc_mode():
934
+ await self._app(scope, receive, send)
935
+ return
936
+ token_str = self._ws_token_from_scope(scope)
937
+ token_info = self._validate_ws_token(token_str)
938
+ if token_info is None or not auth.has_scope(token_info, self._required_scope):
939
+ # ASGI websocket reject: accept-then-close is the portable way to
940
+ # surface a policy violation to the client before any sub-app code
941
+ # runs. 1008 = policy violation.
942
+ await send({"type": "websocket.close", "code": 1008})
943
+ return
944
+ await self._app(scope, receive, send)
945
+ return
946
+ from starlette.requests import Request as _StarletteRequest
947
+ request = _StarletteRequest(scope, receive)
948
+ # Reuse the exact dashboard auth path: get_current_token is a no-op
949
+ # (returns None) when auth is disabled, raises 401 on missing/bad
950
+ # credentials when enabled.
951
+ try:
952
+ credentials = await auth.security(request)
953
+ token_info = await auth.get_current_token(request, credentials)
954
+ except HTTPException as exc:
955
+ await JSONResponse(
956
+ {"detail": exc.detail},
957
+ status_code=exc.status_code,
958
+ headers=exc.headers,
959
+ )(scope, receive, send)
960
+ return
961
+ # token_info is None only when auth is disabled -> allow through.
962
+ if token_info is not None and not auth.has_scope(token_info, self._required_scope):
963
+ await JSONResponse(
964
+ {"detail": f"Insufficient permissions. Required scope: {self._required_scope}"},
965
+ status_code=403,
966
+ )(scope, receive, send)
967
+ return
968
+ await self._app(scope, receive, send)
969
+
970
+
866
971
  _PURPLE_LAB_MOUNTED = False
867
972
  try:
868
973
  import sys as _sys
@@ -871,7 +976,9 @@ try:
871
976
  if str(_webapp_dir) not in _sys.path:
872
977
  _sys.path.insert(0, str(_webapp_dir))
873
978
  import server as _purple_lab_server # type: ignore[import-not-found]
874
- app.mount("/lab", _purple_lab_server.app)
979
+ # Gate the mount so /lab/* requires the same scoped token as the dashboard's
980
+ # own endpoints when enterprise auth is on (no-op when auth is off).
981
+ app.mount("/lab", _MountAuthGuard(_purple_lab_server.app, "read"))
875
982
  _PURPLE_LAB_MOUNTED = True
876
983
  logger.info("Purple Lab mounted at /lab/ (Phase Merge-4)")
877
984
  except Exception as _e: # noqa: BLE001
@@ -6510,8 +6617,8 @@ async def rollback_checkpoint(checkpoint_id: str):
6510
6617
  # Agent Management API (v5.25.0)
6511
6618
  # =============================================================================
6512
6619
 
6513
- @app.get("/api/agents")
6514
- async def get_agents(token: Optional[dict] = Depends(auth.get_current_token)):
6620
+ @app.get("/api/agents", dependencies=[Depends(auth.require_scope("read"))])
6621
+ async def get_agents():
6515
6622
  """Get all active and recent agents."""
6516
6623
  agents_file = _get_loki_dir() / "state" / "agents.json"
6517
6624
  agents = []
@@ -6651,8 +6758,8 @@ async def resume_agent(agent_id: str):
6651
6758
  return {"success": True, "message": f"Resume signal sent to agent {agent_id}"}
6652
6759
 
6653
6760
 
6654
- @app.get("/api/logs")
6655
- async def get_logs(lines: int = Query(default=100, ge=1, le=10000), token: Optional[dict] = Depends(auth.get_current_token)):
6761
+ @app.get("/api/logs", dependencies=[Depends(auth.require_scope("read"))])
6762
+ async def get_logs(lines: int = Query(default=100, ge=1, le=10000)):
6656
6763
  """Get recent log entries from session log files (redacted)."""
6657
6764
  log_dir = _get_loki_dir() / "logs"
6658
6765
  entries = []
@@ -6775,8 +6882,8 @@ async def get_secrets_status():
6775
6882
  # =============================================================================
6776
6883
 
6777
6884
 
6778
- @app.get("/api/github/status")
6779
- async def get_github_status(token: Optional[dict] = Depends(auth.get_current_token)):
6885
+ @app.get("/api/github/status", dependencies=[Depends(auth.require_scope("read"))])
6886
+ async def get_github_status():
6780
6887
  """Get GitHub integration status and configuration."""
6781
6888
  loki_dir = _get_loki_dir()
6782
6889
  result: dict[str, Any] = {
@@ -6835,8 +6942,8 @@ async def get_github_status(token: Optional[dict] = Depends(auth.get_current_tok
6835
6942
  return result
6836
6943
 
6837
6944
 
6838
- @app.get("/api/github/tasks")
6839
- async def get_github_tasks(token: Optional[dict] = Depends(auth.get_current_token)):
6945
+ @app.get("/api/github/tasks", dependencies=[Depends(auth.require_scope("read"))])
6946
+ async def get_github_tasks():
6840
6947
  """Get all GitHub-sourced tasks and their sync status."""
6841
6948
  loki_dir = _get_loki_dir()
6842
6949
  tasks: list[dict] = []
@@ -6875,10 +6982,9 @@ async def get_github_tasks(token: Optional[dict] = Depends(auth.get_current_toke
6875
6982
  return {"tasks": tasks, "total": len(tasks)}
6876
6983
 
6877
6984
 
6878
- @app.get("/api/github/sync-log")
6985
+ @app.get("/api/github/sync-log", dependencies=[Depends(auth.require_scope("read"))])
6879
6986
  async def get_github_sync_log(
6880
6987
  limit: int = Query(default=50, ge=1, le=500),
6881
- token: Optional[dict] = Depends(auth.get_current_token)
6882
6988
  ):
6883
6989
  """Get the GitHub sync log (status updates sent to issues)."""
6884
6990
  loki_dir = _get_loki_dir()
@@ -7007,8 +7113,8 @@ def _resolve_process_state(pid: Optional[int], last_status: str = "",
7007
7113
  return result
7008
7114
 
7009
7115
 
7010
- @app.get("/api/health/processes")
7011
- async def get_process_health(token: Optional[dict] = Depends(auth.get_current_token)):
7116
+ @app.get("/api/health/processes", dependencies=[Depends(auth.require_scope("read"))])
7117
+ async def get_process_health():
7012
7118
  """Get health status of all loki processes (dashboard, session, agents).
7013
7119
 
7014
7120
  Returns honest state labels: RUNNING, STALE, COMPLETED, FAILED, CRASHED, UNKNOWN.
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.76.0
5
+ **Version:** v7.77.0
6
6
 
7
7
  ---
8
8
 
@@ -395,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
395
395
  # Run Loki Mode in Docker (Claude provider, API-key auth)
396
396
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
397
397
  -v $(pwd):/workspace -w /workspace \
398
- asklokesh/loki-mode:7.76.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.77.0 start ./my-spec.md
399
399
  ```
400
400
 
401
401
  ##### docker compose + .env (no host install)