loki-mode 7.48.0 → 7.49.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.48.0
6
+ # Loki Mode v7.49.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -407,4 +407,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
407
407
 
408
408
  ---
409
409
 
410
- **v7.48.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
410
+ **v7.49.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.48.0
1
+ 7.49.0
@@ -1569,6 +1569,19 @@ council_evidence_gate() {
1569
1569
  local test_fails="false"
1570
1570
  local test_runner="none"
1571
1571
  local test_pass="true"
1572
+ # P1-1 (evidence-gate loophole): track WHY the test signal is not conclusive
1573
+ # positive evidence, mirroring diff_inconclusive. A project that ran NO test
1574
+ # suite (runner=="none") must NOT count as affirmative "tests are green"
1575
+ # evidence -- absence of tests is not proof of correctness. We classify it as
1576
+ # INCONCLUSIVE (not FAIL: a no-tests project is still allowed to complete, it
1577
+ # just may not lean on tests as positive proof), so the no-tests "done" routes
1578
+ # to the completion council's affirmative vote instead of silently passing on
1579
+ # diff-alone. test_inconclusive is pass-through by construction: it never sets
1580
+ # test_fails and never writes evidence-block.json, exactly like
1581
+ # diff_inconclusive. Opt-out (LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE=1) reverts to
1582
+ # the historical behavior where runner=="none" was an affirmative PASS.
1583
+ local test_inconclusive="false"
1584
+ local test_inconclusive_reason=""
1572
1585
  if [ -f "$tr_file" ]; then
1573
1586
  local test_status
1574
1587
  test_status=$(_TR_FILE="$tr_file" python3 -c "
@@ -1597,9 +1610,25 @@ else:
1597
1610
  test_fails="true"
1598
1611
  fi
1599
1612
  # INCONCLUSIVE => test_fails stays "false" => pass-through.
1613
+ # No test suite ran: a present results file that records runner=="none"
1614
+ # is not affirmative evidence. Route to council (inconclusive), not a
1615
+ # silent diff-alone pass. Default-on; LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE=1
1616
+ # restores the old affirmative-PASS behavior.
1617
+ if [ "$test_runner" = "none" ] && [ "${LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE:-0}" != "1" ]; then
1618
+ test_inconclusive="true"
1619
+ test_inconclusive_reason="no_test_runner"
1620
+ fi
1621
+ else
1622
+ # Missing test-results.json: no suite was recorded at all. Like the
1623
+ # runner=="none" case this is not affirmative evidence, so classify it
1624
+ # inconclusive (still pass-through: test_fails stays "false"). Preserves
1625
+ # the historical "no file = no gate" non-blocking behavior while making
1626
+ # the absence auditable instead of silently affirmative.
1627
+ if [ "${LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE:-0}" != "1" ]; then
1628
+ test_inconclusive="true"
1629
+ test_inconclusive_reason="no_test_results"
1630
+ fi
1600
1631
  fi
1601
- # Missing test-results.json (the else of the -f check) likewise leaves
1602
- # test_fails="false" => inconclusive => pass-through (no file = no gate).
1603
1632
 
1604
1633
  # --- v7.28.0: inconclusive-baseline lifecycle -------------------------------
1605
1634
  # When the gate cannot establish a diff baseline (no git repo, or no run-start
@@ -1635,12 +1664,63 @@ INCONCLUSIVE_EOF
1635
1664
  fi
1636
1665
  fi
1637
1666
 
1667
+ # --- P1-1: durable, auditable evidence-gate details -------------------------
1668
+ # Persist the full evidence picture on EVERY gate run (pass and block) so any
1669
+ # completion claim is auditable after the fact: diff status, test runner +
1670
+ # status, both inconclusive reasons, and the final verdict. Atomic temp+mv,
1671
+ # under .loki/council/ (already excluded from the diff union by the gate's own
1672
+ # ^\.loki/ filter, so it never makes the gate toothless). Best-effort: a write
1673
+ # failure never changes the gate's decision. _write_evidence_details <verdict>
1674
+ # where verdict is one of pass|block (the caller passes the decided verdict).
1675
+ _write_evidence_details() {
1676
+ local _verdict="$1"
1677
+ mkdir -p "$COUNCIL_STATE_DIR" 2>/dev/null || true
1678
+ local _det_file="$COUNCIL_STATE_DIR/evidence-gate-details.json"
1679
+ local _det_tmp="${_det_file}.tmp"
1680
+ local _det_ts
1681
+ _det_ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1682
+ local _diff_ok _tests_ok
1683
+ if [ "$diff_fails" = "true" ]; then _diff_ok="false"; else _diff_ok="true"; fi
1684
+ if [ "$test_fails" = "true" ]; then _tests_ok="false"; else _tests_ok="true"; fi
1685
+ cat > "$_det_tmp" << DETAILS_EOF
1686
+ {
1687
+ "recorded_at": "$_det_ts",
1688
+ "iteration": ${ITERATION_COUNT:-0},
1689
+ "verdict": "$_verdict",
1690
+ "diff": {
1691
+ "ok": $_diff_ok,
1692
+ "base_sha": "${base_sha:-}",
1693
+ "files_changed": $diff_files,
1694
+ "inconclusive": $diff_inconclusive,
1695
+ "inconclusive_reason": "$diff_inconclusive_reason"
1696
+ },
1697
+ "tests": {
1698
+ "ok": $_tests_ok,
1699
+ "runner": "$test_runner",
1700
+ "pass": $test_pass,
1701
+ "inconclusive": $test_inconclusive,
1702
+ "inconclusive_reason": "$test_inconclusive_reason"
1703
+ }
1704
+ }
1705
+ DETAILS_EOF
1706
+ mv "$_det_tmp" "$_det_file" 2>/dev/null || rm -f "$_det_tmp" 2>/dev/null || true
1707
+ }
1708
+
1638
1709
  # --- Block decision: block iff DIFF FAILS or TEST FAILS ---
1639
1710
  if [ "$diff_fails" != "true" ] && [ "$test_fails" != "true" ]; then
1640
1711
  # Gate passes: remove any stale block report.
1641
1712
  if [ -f "$COUNCIL_STATE_DIR/evidence-block.json" ]; then
1642
1713
  rm -f "$COUNCIL_STATE_DIR/evidence-block.json"
1643
1714
  fi
1715
+ # P1-1: when the gate passes ONLY because no test suite ran, say so out
1716
+ # loud. The pass is pass-through (no-tests must not deadlock), but a
1717
+ # completion that is not backed by any test evidence should never slip by
1718
+ # silently. The durable detail is in evidence-gate-details.json; this is
1719
+ # the human-visible honesty at the pass site.
1720
+ if [ "$test_inconclusive" = "true" ]; then
1721
+ log_warn "[Council] Evidence gate: completion not backed by test evidence (${test_inconclusive_reason}). Pass-through; set LOKI_EVIDENCE_NO_TESTS_AFFIRMATIVE=1 to treat no-tests as affirmative."
1722
+ fi
1723
+ _write_evidence_details "pass"
1644
1724
  return 0
1645
1725
  fi
1646
1726
 
@@ -1718,6 +1798,9 @@ EVIDENCE_EOF
1718
1798
  >/dev/null 2>&1 || true
1719
1799
  fi
1720
1800
 
1801
+ # P1-1: durable audit record for the block path too (see _write_evidence_details).
1802
+ _write_evidence_details "block"
1803
+
1721
1804
  return 1
1722
1805
  }
1723
1806
 
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.48.0"
10
+ __version__ = "7.49.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -20,6 +20,7 @@ from typing import Any, Optional
20
20
  from fastapi import APIRouter, Depends, HTTPException, Query, Request
21
21
  from fastapi.responses import StreamingResponse
22
22
  from pydantic import BaseModel, Field
23
+ from sqlalchemy import select
23
24
  from sqlalchemy.ext.asyncio import AsyncSession
24
25
 
25
26
  from . import auth
@@ -28,6 +29,7 @@ from . import api_keys
28
29
  from . import tenants as tenants_mod
29
30
  from . import runs as runs_mod
30
31
  from .database import get_db
32
+ from .models import Project, Run
31
33
 
32
34
 
33
35
  # ---------------------------------------------------------------------------
@@ -120,6 +122,178 @@ def _audit_context(request: Request, token_info: Optional[dict] = None) -> dict:
120
122
  }
121
123
 
122
124
 
125
+ # ---------------------------------------------------------------------------
126
+ # Tenant isolation (P3-7)
127
+ # ---------------------------------------------------------------------------
128
+ #
129
+ # The audit (backlog P3-7) flagged that API-level cross-tenant access was NOT
130
+ # enforced: any authenticated caller with the `read` scope could list every
131
+ # tenant and read any tenant's projects, regardless of which tenant they
132
+ # belong to. A caller authenticated for tenant A could read tenant B's data.
133
+ #
134
+ # The caller's tenant MUST come from the trusted, server-validated token --
135
+ # never from a client-supplied request header, which the caller could simply
136
+ # set to a victim tenant's id and bypass the boundary entirely. The auth layer
137
+ # (dashboard/auth.py) has no dedicated tenant field, but a validated token's
138
+ # `scopes` list IS trusted: it is returned by validate_token() (from the
139
+ # server-side token store) and by validate_oidc_token() (from
140
+ # cryptographically/issuer-validated claims). We therefore bind a token to a
141
+ # tenant via a `tenant:<id>` scope, parsed out of token_info["scopes"]. To
142
+ # scope a token to tenant 5, mint it with that scope in its scope list, e.g.
143
+ # via the API-key endpoint or auth.generate_token:
144
+ #
145
+ # POST /api/v2/api-keys {"name": "...", "scopes": ["read", "tenant:5"]}
146
+ # auth.generate_token(name="...", scopes=["read", "tenant:5"])
147
+ #
148
+ # A token's scopes decide crossing rights:
149
+ #
150
+ # * A global admin (scope `*`, i.e. the admin role) may cross any tenant.
151
+ # * A non-admin token is pinned to the tenant in its `tenant:<id>` scope;
152
+ # a request that targets a different tenant is denied with 403.
153
+ # * When auth is disabled (no enterprise token auth and no OIDC) there is no
154
+ # caller identity to isolate -- this is single-user local mode -- so access
155
+ # is not restricted.
156
+ #
157
+ # This boundary is deliberately fail-closed for authenticated non-admin
158
+ # callers: a token with no `tenant:<id>` scope cannot reach ANY tenant-scoped
159
+ # resource (every cross-tenant check fails), so an un-scoped token is not
160
+ # silently granted access to a tenant it was never bound to.
161
+
162
+ TENANT_SCOPE_PREFIX = "tenant:"
163
+
164
+
165
+ class TenantContext:
166
+ """Resolved tenant boundary for the current caller.
167
+
168
+ Attributes:
169
+ tenant_id: The tenant the caller's token is bound to (parsed from its
170
+ trusted `tenant:<id>` scope), or None if the token carries no
171
+ tenant scope.
172
+ is_global_admin: True if the caller holds admin scope and may
173
+ legitimately cross tenant boundaries.
174
+ auth_enabled: True if any auth method (token or OIDC) is active.
175
+ """
176
+
177
+ __slots__ = ("tenant_id", "is_global_admin", "auth_enabled")
178
+
179
+ def __init__(
180
+ self,
181
+ tenant_id: Optional[int],
182
+ is_global_admin: bool,
183
+ auth_enabled: bool,
184
+ ) -> None:
185
+ self.tenant_id = tenant_id
186
+ self.is_global_admin = is_global_admin
187
+ self.auth_enabled = auth_enabled
188
+
189
+ def enforce(self, target_tenant_id: int) -> None:
190
+ """Raise 403 if the caller may not access the given tenant's resources.
191
+
192
+ A global admin may access any tenant. When auth is disabled there is
193
+ no caller to isolate, so access is allowed. Otherwise the caller's
194
+ token-bound tenant must exactly match the target tenant.
195
+ """
196
+ if self.is_global_admin or not self.auth_enabled:
197
+ return
198
+ if self.tenant_id is None or self.tenant_id != target_tenant_id:
199
+ raise HTTPException(
200
+ status_code=403,
201
+ detail="Cross-tenant access denied",
202
+ )
203
+
204
+
205
+ def _tenant_id_from_token(token_info: Optional[dict]) -> Optional[int]:
206
+ """Extract the caller's tenant id from a validated token's scopes.
207
+
208
+ Looks for a single `tenant:<id>` scope in the (trusted) token scope list.
209
+ Returns None if the token carries no such scope. If the token carries
210
+ conflicting tenant scopes (more than one distinct tenant), access is
211
+ denied (403) rather than silently picking one.
212
+ """
213
+ if not token_info:
214
+ return None
215
+ found: set[int] = set()
216
+ for scope in token_info.get("scopes", []):
217
+ if isinstance(scope, str) and scope.startswith(TENANT_SCOPE_PREFIX):
218
+ raw = scope[len(TENANT_SCOPE_PREFIX):].strip()
219
+ try:
220
+ found.add(int(raw))
221
+ except (TypeError, ValueError):
222
+ # Malformed tenant scope -- ignore it (does not grant access).
223
+ continue
224
+ if not found:
225
+ return None
226
+ if len(found) > 1:
227
+ raise HTTPException(
228
+ status_code=403,
229
+ detail="Token carries conflicting tenant scopes",
230
+ )
231
+ return next(iter(found))
232
+
233
+
234
+ def resolve_tenant_context(
235
+ token_info: Optional[dict] = Depends(auth.get_current_token),
236
+ ) -> TenantContext:
237
+ """FastAPI dependency that resolves the caller's tenant boundary.
238
+
239
+ The caller's tenant is derived solely from the trusted, server-validated
240
+ token (its `tenant:<id>` scope); whether the caller is a global admin (and
241
+ may cross tenants) is read from the same token's scopes. No client-supplied
242
+ header is consulted, so a caller cannot impersonate another tenant.
243
+ """
244
+ auth_enabled = auth.is_enterprise_mode() or auth.is_oidc_mode()
245
+ is_global_admin = bool(token_info) and auth.has_scope(token_info, "admin")
246
+ tenant_id = _tenant_id_from_token(token_info)
247
+ return TenantContext(
248
+ tenant_id=tenant_id,
249
+ is_global_admin=is_global_admin,
250
+ auth_enabled=auth_enabled,
251
+ )
252
+
253
+
254
+ async def _enforce_project_tenant(
255
+ db: AsyncSession, tenant_ctx: TenantContext, project_id: int
256
+ ) -> None:
257
+ """Enforce the tenant boundary for a project referenced by id.
258
+
259
+ Loads the project's tenant_id and applies tenant_ctx.enforce. A missing
260
+ project yields 404 (so existence is not leaked across tenants any more
261
+ than the boundary already allows). For a global admin / auth-disabled
262
+ caller this is a cheap pass-through that does not need the lookup.
263
+ """
264
+ if tenant_ctx.is_global_admin or not tenant_ctx.auth_enabled:
265
+ return
266
+ result = await db.execute(
267
+ select(Project.tenant_id).where(Project.id == project_id)
268
+ )
269
+ owner_tenant_id = result.scalar_one_or_none()
270
+ if owner_tenant_id is None:
271
+ raise HTTPException(status_code=404, detail="Project not found")
272
+ tenant_ctx.enforce(owner_tenant_id)
273
+
274
+
275
+ async def _enforce_run_tenant(
276
+ db: AsyncSession, tenant_ctx: TenantContext, run_id: int
277
+ ) -> None:
278
+ """Enforce the tenant boundary for a run referenced by id.
279
+
280
+ A run belongs to a project, which belongs to a tenant. We resolve the
281
+ run -> project -> tenant chain and apply the boundary. A missing run
282
+ yields 404.
283
+ """
284
+ if tenant_ctx.is_global_admin or not tenant_ctx.auth_enabled:
285
+ return
286
+ result = await db.execute(
287
+ select(Project.tenant_id)
288
+ .join(Run, Run.project_id == Project.id)
289
+ .where(Run.id == run_id)
290
+ )
291
+ owner_tenant_id = result.scalar_one_or_none()
292
+ if owner_tenant_id is None:
293
+ raise HTTPException(status_code=404, detail="Run not found")
294
+ tenant_ctx.enforce(owner_tenant_id)
295
+
296
+
123
297
  # ===========================================================================
124
298
  # TENANT ENDPOINTS
125
299
  # ===========================================================================
@@ -149,18 +323,33 @@ async def create_tenant(
149
323
  @router.get("/tenants", dependencies=[Depends(auth.require_scope("read"))])
150
324
  async def list_tenants(
151
325
  db: AsyncSession = Depends(get_db),
326
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
152
327
  ):
153
- """List all tenants."""
328
+ """List tenants visible to the caller.
329
+
330
+ A global admin sees every tenant. A tenant-scoped caller sees only their
331
+ own tenant (and an empty list if the token carries no `tenant:<id>`
332
+ scope). When auth is disabled, all tenants are returned (single-user
333
+ local mode).
334
+ """
154
335
  items = await tenants_mod.list_tenants(db)
155
- return [tenants_mod._tenant_to_response(t) for t in items]
336
+ if tenant_ctx.is_global_admin or not tenant_ctx.auth_enabled:
337
+ return [tenants_mod._tenant_to_response(t) for t in items]
338
+ return [
339
+ tenants_mod._tenant_to_response(t)
340
+ for t in items
341
+ if t.id == tenant_ctx.tenant_id
342
+ ]
156
343
 
157
344
 
158
345
  @router.get("/tenants/{tenant_id}", dependencies=[Depends(auth.require_scope("read"))])
159
346
  async def get_tenant(
160
347
  tenant_id: int,
161
348
  db: AsyncSession = Depends(get_db),
349
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
162
350
  ):
163
- """Get a tenant by ID."""
351
+ """Get a tenant by ID, scoped to the caller's tenant boundary."""
352
+ tenant_ctx.enforce(tenant_id)
164
353
  tenant = await tenants_mod.get_tenant(db, tenant_id)
165
354
  if tenant is None:
166
355
  raise HTTPException(status_code=404, detail="Tenant not found")
@@ -175,8 +364,10 @@ async def update_tenant(
175
364
  db: AsyncSession = Depends(get_db),
176
365
  _auth: None = Depends(auth.require_scope("control")),
177
366
  token_info: Optional[dict] = Depends(auth.get_current_token),
367
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
178
368
  ):
179
369
  """Update an existing tenant."""
370
+ tenant_ctx.enforce(tenant_id)
180
371
  tenant = await tenants_mod.update_tenant(
181
372
  db, tenant_id,
182
373
  name=body.name, description=body.description, settings=body.settings,
@@ -199,8 +390,10 @@ async def delete_tenant(
199
390
  db: AsyncSession = Depends(get_db),
200
391
  _auth: None = Depends(auth.require_scope("control")),
201
392
  token_info: Optional[dict] = Depends(auth.get_current_token),
393
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
202
394
  ):
203
395
  """Delete a tenant."""
396
+ tenant_ctx.enforce(tenant_id)
204
397
  deleted = await tenants_mod.delete_tenant(db, tenant_id)
205
398
  if not deleted:
206
399
  raise HTTPException(status_code=404, detail="Tenant not found")
@@ -216,8 +409,10 @@ async def delete_tenant(
216
409
  async def get_tenant_projects(
217
410
  tenant_id: int,
218
411
  db: AsyncSession = Depends(get_db),
412
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
219
413
  ):
220
- """List all projects for a tenant."""
414
+ """List all projects for a tenant, scoped to the caller's tenant boundary."""
415
+ tenant_ctx.enforce(tenant_id)
221
416
  tenant = await tenants_mod.get_tenant(db, tenant_id)
222
417
  if tenant is None:
223
418
  raise HTTPException(status_code=404, detail="Tenant not found")
@@ -248,8 +443,10 @@ async def create_run(
248
443
  db: AsyncSession = Depends(get_db),
249
444
  _auth: None = Depends(auth.require_scope("control")),
250
445
  token_info: Optional[dict] = Depends(auth.get_current_token),
446
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
251
447
  ):
252
- """Create a new run."""
448
+ """Create a new run, scoped to the caller's tenant boundary."""
449
+ await _enforce_project_tenant(db, tenant_ctx, body.project_id)
253
450
  run_resp = await runs_mod.create_run(
254
451
  db, project_id=body.project_id, trigger=body.trigger, config=body.config,
255
452
  )
@@ -268,19 +465,62 @@ async def list_runs(
268
465
  limit: int = Query(50, ge=1, le=1000),
269
466
  offset: int = Query(0, ge=0),
270
467
  db: AsyncSession = Depends(get_db),
468
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
271
469
  ):
272
- """List runs with optional filters."""
273
- return await runs_mod.list_runs(
274
- db, project_id=project_id, status=status, limit=limit, offset=offset,
470
+ """List runs with optional filters, scoped to the caller's tenant.
471
+
472
+ A global admin / auth-disabled caller sees all runs. A tenant-scoped
473
+ caller only ever sees runs whose project belongs to their tenant: an
474
+ explicit project_id is enforced against the boundary, and an unscoped
475
+ listing is narrowed to the caller's own projects.
476
+ """
477
+ if tenant_ctx.is_global_admin or not tenant_ctx.auth_enabled:
478
+ return await runs_mod.list_runs(
479
+ db, project_id=project_id, status=status, limit=limit, offset=offset,
480
+ )
481
+
482
+ if project_id is not None:
483
+ # Targeting a specific project: enforce it belongs to the caller.
484
+ await _enforce_project_tenant(db, tenant_ctx, project_id)
485
+ return await runs_mod.list_runs(
486
+ db, project_id=project_id, status=status, limit=limit, offset=offset,
487
+ )
488
+
489
+ # No project filter: restrict to the caller's own projects. A caller with
490
+ # no tenant binding sees nothing (fail-closed).
491
+ if tenant_ctx.tenant_id is None:
492
+ return []
493
+ proj_result = await db.execute(
494
+ select(Project.id).where(Project.tenant_id == tenant_ctx.tenant_id)
275
495
  )
496
+ owned_project_ids = [row[0] for row in proj_result]
497
+ if not owned_project_ids:
498
+ return []
499
+ # Collect this tenant's runs across all its projects, then apply the
500
+ # caller's limit/offset ONCE over the globally-sorted result so pagination
501
+ # is correct (not per-project). We over-fetch up to limit+offset per
502
+ # project to guarantee the merged top window is complete, then sort by
503
+ # created_at desc and slice. Isolation is unconditional regardless.
504
+ fetch_cap = limit + offset
505
+ collected: list = []
506
+ for pid in owned_project_ids:
507
+ collected.extend(
508
+ await runs_mod.list_runs(
509
+ db, project_id=pid, status=status, limit=fetch_cap, offset=0,
510
+ )
511
+ )
512
+ collected.sort(key=lambda r: r.created_at, reverse=True)
513
+ return collected[offset:offset + limit]
276
514
 
277
515
 
278
516
  @router.get("/runs/{run_id}", dependencies=[Depends(auth.require_scope("read"))])
279
517
  async def get_run(
280
518
  run_id: int,
281
519
  db: AsyncSession = Depends(get_db),
520
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
282
521
  ):
283
- """Get run details by ID."""
522
+ """Get run details by ID, scoped to the caller's tenant boundary."""
523
+ await _enforce_run_tenant(db, tenant_ctx, run_id)
284
524
  run_resp = await runs_mod.get_run(db, run_id)
285
525
  if run_resp is None:
286
526
  raise HTTPException(status_code=404, detail="Run not found")
@@ -294,8 +534,10 @@ async def cancel_run(
294
534
  db: AsyncSession = Depends(get_db),
295
535
  _auth: None = Depends(auth.require_scope("control")),
296
536
  token_info: Optional[dict] = Depends(auth.get_current_token),
537
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
297
538
  ):
298
- """Cancel a running run."""
539
+ """Cancel a running run, scoped to the caller's tenant boundary."""
540
+ await _enforce_run_tenant(db, tenant_ctx, run_id)
299
541
  run_resp = await runs_mod.cancel_run(db, run_id)
300
542
  if run_resp is None:
301
543
  raise HTTPException(status_code=404, detail="Run not found")
@@ -313,8 +555,10 @@ async def replay_run(
313
555
  db: AsyncSession = Depends(get_db),
314
556
  _auth: None = Depends(auth.require_scope("control")),
315
557
  token_info: Optional[dict] = Depends(auth.get_current_token),
558
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
316
559
  ):
317
- """Replay a run."""
560
+ """Replay a run, scoped to the caller's tenant boundary."""
561
+ await _enforce_run_tenant(db, tenant_ctx, run_id)
318
562
  run_resp = await runs_mod.replay_run(db, run_id)
319
563
  if run_resp is None:
320
564
  raise HTTPException(status_code=404, detail="Run not found")
@@ -329,8 +573,10 @@ async def replay_run(
329
573
  async def get_run_timeline(
330
574
  run_id: int,
331
575
  db: AsyncSession = Depends(get_db),
576
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
332
577
  ):
333
- """Get the timeline of events for a run."""
578
+ """Get the timeline of events for a run, scoped to the caller's tenant."""
579
+ await _enforce_run_tenant(db, tenant_ctx, run_id)
334
580
  timeline = await runs_mod.get_run_timeline(db, run_id)
335
581
  if timeline is None:
336
582
  return {"run_id": run_id, "phases": [], "current_phase": None, "events": []}
@@ -55,6 +55,11 @@ from . import app_secrets as secrets_mod
55
55
  from . import telemetry as _telemetry
56
56
  from .control import atomic_write_json, find_skill_dir, is_process_running
57
57
  from .activity_logger import get_activity_logger
58
+ from .api_v2 import (
59
+ TenantContext,
60
+ _enforce_project_tenant,
61
+ resolve_tenant_context,
62
+ )
58
63
 
59
64
  try:
60
65
  from . import __version__ as _version
@@ -267,6 +272,7 @@ class ProjectResponse(BaseModel):
267
272
  description: Optional[str]
268
273
  prd_path: Optional[str]
269
274
  status: str
275
+ tenant_id: int
270
276
  created_at: datetime
271
277
  updated_at: datetime
272
278
  task_count: int = 0
@@ -1255,14 +1261,25 @@ async def list_projects(
1255
1261
  limit: int = Query(default=50, ge=1, le=500),
1256
1262
  offset: int = Query(default=0, ge=0),
1257
1263
  db: AsyncSession = Depends(get_db),
1264
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
1258
1265
  ) -> list[ProjectResponse]:
1259
- """List projects with pagination. Does not eager-load tasks for efficiency."""
1266
+ """List projects with pagination. Does not eager-load tasks for efficiency.
1267
+
1268
+ Tenant isolation (P3-7): a non-admin authenticated caller only sees
1269
+ projects belonging to their declared tenant (X-Loki-Tenant-ID). A global
1270
+ admin and single-user local mode (auth disabled) see all projects.
1271
+ """
1260
1272
  try:
1261
1273
  from sqlalchemy import func as sa_func
1262
1274
 
1263
1275
  query = select(Project)
1264
1276
  if status:
1265
1277
  query = query.where(Project.status == status)
1278
+ if not tenant_ctx.is_global_admin and tenant_ctx.auth_enabled:
1279
+ # Pin to the caller's tenant. A None tenant_id yields no matches,
1280
+ # which is the correct fail-closed behaviour for a scoped caller
1281
+ # that did not declare a tenant.
1282
+ query = query.where(Project.tenant_id == tenant_ctx.tenant_id)
1266
1283
  query = query.order_by(Project.created_at.desc()).offset(offset).limit(limit)
1267
1284
 
1268
1285
  result = await db.execute(query)
@@ -1295,6 +1312,7 @@ async def list_projects(
1295
1312
  description=project.description,
1296
1313
  prd_path=project.prd_path,
1297
1314
  status=project.status,
1315
+ tenant_id=project.tenant_id,
1298
1316
  created_at=project.created_at,
1299
1317
  updated_at=project.updated_at,
1300
1318
  task_count=total,
@@ -1311,8 +1329,14 @@ async def list_projects(
1311
1329
  async def create_project(
1312
1330
  project: ProjectCreate,
1313
1331
  db: AsyncSession = Depends(get_db),
1332
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
1314
1333
  ) -> ProjectResponse:
1315
- """Create a new project."""
1334
+ """Create a new project.
1335
+
1336
+ Tenant isolation (P3-7): a non-admin caller may only create projects in
1337
+ their own declared tenant; targeting another tenant returns 403.
1338
+ """
1339
+ tenant_ctx.enforce(project.tenant_id)
1316
1340
  # Validate tenant exists
1317
1341
  tenant_result = await db.execute(
1318
1342
  select(Tenant).where(Tenant.id == project.tenant_id)
@@ -1342,6 +1366,7 @@ async def create_project(
1342
1366
  description=db_project.description,
1343
1367
  prd_path=db_project.prd_path,
1344
1368
  status=db_project.status,
1369
+ tenant_id=db_project.tenant_id,
1345
1370
  created_at=db_project.created_at,
1346
1371
  updated_at=db_project.updated_at,
1347
1372
  task_count=0,
@@ -1353,8 +1378,9 @@ async def create_project(
1353
1378
  async def get_project(
1354
1379
  project_id: int,
1355
1380
  db: AsyncSession = Depends(get_db),
1381
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
1356
1382
  ) -> ProjectResponse:
1357
- """Get a project by ID."""
1383
+ """Get a project by ID, scoped to the caller's tenant boundary."""
1358
1384
  result = await db.execute(
1359
1385
  select(Project)
1360
1386
  .options(selectinload(Project.tasks))
@@ -1365,6 +1391,8 @@ async def get_project(
1365
1391
  if not project:
1366
1392
  raise HTTPException(status_code=404, detail="Project not found")
1367
1393
 
1394
+ tenant_ctx.enforce(project.tenant_id)
1395
+
1368
1396
  task_count = len(project.tasks)
1369
1397
  completed_count = len([t for t in project.tasks if t.status == TaskStatus.DONE])
1370
1398
 
@@ -1374,6 +1402,7 @@ async def get_project(
1374
1402
  description=project.description,
1375
1403
  prd_path=project.prd_path,
1376
1404
  status=project.status,
1405
+ tenant_id=project.tenant_id,
1377
1406
  created_at=project.created_at,
1378
1407
  updated_at=project.updated_at,
1379
1408
  task_count=task_count,
@@ -1386,8 +1415,9 @@ async def update_project(
1386
1415
  project_id: int,
1387
1416
  project_update: ProjectUpdate,
1388
1417
  db: AsyncSession = Depends(get_db),
1418
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
1389
1419
  ) -> ProjectResponse:
1390
- """Update a project."""
1420
+ """Update a project, scoped to the caller's tenant boundary."""
1391
1421
  result = await db.execute(
1392
1422
  select(Project)
1393
1423
  .options(selectinload(Project.tasks))
@@ -1398,6 +1428,8 @@ async def update_project(
1398
1428
  if not project:
1399
1429
  raise HTTPException(status_code=404, detail="Project not found")
1400
1430
 
1431
+ tenant_ctx.enforce(project.tenant_id)
1432
+
1401
1433
  update_data = project_update.model_dump(exclude_unset=True)
1402
1434
  for field, value in update_data.items():
1403
1435
  setattr(project, field, value)
@@ -1420,6 +1452,7 @@ async def update_project(
1420
1452
  description=project.description,
1421
1453
  prd_path=project.prd_path,
1422
1454
  status=project.status,
1455
+ tenant_id=project.tenant_id,
1423
1456
  created_at=project.created_at,
1424
1457
  updated_at=project.updated_at,
1425
1458
  task_count=task_count,
@@ -1432,8 +1465,9 @@ async def delete_project(
1432
1465
  project_id: int,
1433
1466
  request: Request,
1434
1467
  db: AsyncSession = Depends(get_db),
1468
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
1435
1469
  ) -> None:
1436
- """Delete a project."""
1470
+ """Delete a project, scoped to the caller's tenant boundary."""
1437
1471
  result = await db.execute(
1438
1472
  select(Project).where(Project.id == project_id)
1439
1473
  )
@@ -1442,6 +1476,8 @@ async def delete_project(
1442
1476
  if not project:
1443
1477
  raise HTTPException(status_code=404, detail="Project not found")
1444
1478
 
1479
+ tenant_ctx.enforce(project.tenant_id)
1480
+
1445
1481
  audit.log_event(
1446
1482
  action="delete",
1447
1483
  resource_type="project",
@@ -1702,8 +1738,11 @@ async def list_tasks(
1702
1738
  async def create_task(
1703
1739
  task: TaskCreate,
1704
1740
  db: AsyncSession = Depends(get_db),
1741
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
1705
1742
  ) -> TaskResponse:
1706
- """Create a new task."""
1743
+ """Create a new task, scoped to the caller's tenant boundary."""
1744
+ # Enforce the tenant boundary on the target project (also 404s if missing).
1745
+ await _enforce_project_tenant(db, tenant_ctx, task.project_id)
1707
1746
  # Verify project exists
1708
1747
  result = await db.execute(
1709
1748
  select(Project).where(Project.id == task.project_id)
@@ -1777,8 +1816,9 @@ async def create_task(
1777
1816
  async def get_task(
1778
1817
  task_id: int,
1779
1818
  db: AsyncSession = Depends(get_db),
1819
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
1780
1820
  ) -> TaskResponse:
1781
- """Get a task by ID."""
1821
+ """Get a task by ID, scoped to the caller's tenant boundary."""
1782
1822
  result = await db.execute(
1783
1823
  select(Task).where(Task.id == task_id)
1784
1824
  )
@@ -1787,6 +1827,8 @@ async def get_task(
1787
1827
  if not task:
1788
1828
  raise HTTPException(status_code=404, detail="Task not found")
1789
1829
 
1830
+ await _enforce_project_tenant(db, tenant_ctx, task.project_id)
1831
+
1790
1832
  return _task_response_from_db(task)
1791
1833
 
1792
1834
 
@@ -1795,8 +1837,9 @@ async def update_task(
1795
1837
  task_id: int,
1796
1838
  task_update: TaskUpdate,
1797
1839
  db: AsyncSession = Depends(get_db),
1840
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
1798
1841
  ) -> TaskResponse:
1799
- """Update a task."""
1842
+ """Update a task, scoped to the caller's tenant boundary."""
1800
1843
  result = await db.execute(
1801
1844
  select(Task).where(Task.id == task_id)
1802
1845
  )
@@ -1805,6 +1848,8 @@ async def update_task(
1805
1848
  if not task:
1806
1849
  raise HTTPException(status_code=404, detail="Task not found")
1807
1850
 
1851
+ await _enforce_project_tenant(db, tenant_ctx, task.project_id)
1852
+
1808
1853
  update_data = task_update.model_dump(exclude_unset=True)
1809
1854
 
1810
1855
  # Handle status change to/from completed
@@ -1844,8 +1889,9 @@ async def delete_task(
1844
1889
  task_id: int,
1845
1890
  request: Request,
1846
1891
  db: AsyncSession = Depends(get_db),
1892
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
1847
1893
  ) -> None:
1848
- """Delete a task."""
1894
+ """Delete a task, scoped to the caller's tenant boundary."""
1849
1895
  result = await db.execute(
1850
1896
  select(Task).where(Task.id == task_id)
1851
1897
  )
@@ -1854,6 +1900,8 @@ async def delete_task(
1854
1900
  if not task:
1855
1901
  raise HTTPException(status_code=404, detail="Task not found")
1856
1902
 
1903
+ await _enforce_project_tenant(db, tenant_ctx, task.project_id)
1904
+
1857
1905
  project_id = task.project_id
1858
1906
 
1859
1907
  audit.log_event(
@@ -1888,8 +1936,12 @@ async def move_task(
1888
1936
  task_id: int,
1889
1937
  move: TaskMove,
1890
1938
  db: AsyncSession = Depends(get_db),
1939
+ tenant_ctx: TenantContext = Depends(resolve_tenant_context),
1891
1940
  ) -> TaskResponse:
1892
- """Move a task to a new status/position (for Kanban drag-and-drop)."""
1941
+ """Move a task to a new status/position (for Kanban drag-and-drop).
1942
+
1943
+ Scoped to the caller's tenant boundary.
1944
+ """
1893
1945
  result = await db.execute(
1894
1946
  select(Task).where(Task.id == task_id)
1895
1947
  )
@@ -1898,6 +1950,8 @@ async def move_task(
1898
1950
  if not task:
1899
1951
  raise HTTPException(status_code=404, detail="Task not found")
1900
1952
 
1953
+ await _enforce_project_tenant(db, tenant_ctx, task.project_id)
1954
+
1901
1955
  old_status = task.status
1902
1956
 
1903
1957
  # Validate status transition
@@ -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.48.0
5
+ **Version:** v7.49.0
6
6
 
7
7
  ---
8
8
 
@@ -396,7 +396,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
396
396
  # Run Loki Mode in Docker (Claude provider, API-key auth)
397
397
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
398
398
  -v $(pwd):/workspace -w /workspace \
399
- asklokesh/loki-mode:7.48.0 start ./my-spec.md
399
+ asklokesh/loki-mode:7.49.0 start ./my-spec.md
400
400
  ```
401
401
 
402
402
  ##### docker compose + .env (no host install)
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var n6=Object.defineProperty;var a6=($)=>$;function s6($,Q){this[$]=a6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)n6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:s6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var S1={};h(S1,{lokiDir:()=>P,homeLokiDir:()=>o$,findRepoRootForVersion:()=>d$,REPO_ROOT:()=>g});import{resolve as n,dirname as l$}from"path";import{fileURLToPath as t6}from"url";import{existsSync as P$}from"fs";import{homedir as r6}from"os";function i6(){let $=N1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=l$($);if(Z===$)break;$=Z}return n(N1,"..","..","..")}function d$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=l$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function o$(){return n(r6(),".loki")}var N1,g;var C=L(()=>{N1=l$(t6(import.meta.url));g=i6()});import{readFileSync as e6}from"fs";import{resolve as $Q,dirname as QQ}from"path";import{fileURLToPath as ZQ}from"url";function F$(){if($$!==null)return $$;let $="7.48.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=QQ(ZQ(import.meta.url)),Z=d$(Q);$$=e6($Q(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var n$=L(()=>{C()});var C1={};h(C1,{runOrThrow:()=>zQ,run:()=>j,commandVersion:()=>KQ,commandExists:()=>f,ShellError:()=>a$});async function j($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[W,K,U]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:W,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function zQ($,Q={}){let Z=await j($,Q);if(Z.exitCode!==0)throw new a$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=XQ($),Z=await j(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function XQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function KQ($,Q="--version"){if(!await f($))return null;let z=await j([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var a$;var d=L(()=>{a$=class a$ extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function a($){return WQ?"":$}var WQ,T,S,I,TZ,w,R,y,q;var c=L(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),I=a("\x1B[1;33m"),TZ=a("\x1B[0;34m"),w=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),q=a("\x1B[0m")});import{existsSync as TQ}from"fs";async function Q$(){if(B$!==void 0)return B$;let $="/opt/homebrew/bin/python3.12";if(TQ($))return B$=$,$;let Q=await f("python3.12");if(Q)return B$=Q,Q;let Z=await f("python3");return B$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return j([Z,"-c",$],Q)}var B$;var W$=L(()=>{d()});var t1={};h(t1,{runStatus:()=>mQ});import{existsSync as v,readFileSync as U$,readdirSync as l1,statSync as d1}from"fs";import{resolve as D,basename as xQ}from"path";import{homedir as NQ}from"os";async function DQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${q}
2
+ var n6=Object.defineProperty;var a6=($)=>$;function s6($,Q){this[$]=a6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)n6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:s6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var S1={};h(S1,{lokiDir:()=>P,homeLokiDir:()=>o$,findRepoRootForVersion:()=>d$,REPO_ROOT:()=>g});import{resolve as n,dirname as l$}from"path";import{fileURLToPath as t6}from"url";import{existsSync as P$}from"fs";import{homedir as r6}from"os";function i6(){let $=N1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=l$($);if(Z===$)break;$=Z}return n(N1,"..","..","..")}function d$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=l$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function o$(){return n(r6(),".loki")}var N1,g;var C=L(()=>{N1=l$(t6(import.meta.url));g=i6()});import{readFileSync as e6}from"fs";import{resolve as $Q,dirname as QQ}from"path";import{fileURLToPath as ZQ}from"url";function F$(){if($$!==null)return $$;let $="7.49.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=QQ(ZQ(import.meta.url)),Z=d$(Q);$$=e6($Q(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var n$=L(()=>{C()});var C1={};h(C1,{runOrThrow:()=>zQ,run:()=>j,commandVersion:()=>KQ,commandExists:()=>f,ShellError:()=>a$});async function j($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[W,K,U]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:W,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function zQ($,Q={}){let Z=await j($,Q);if(Z.exitCode!==0)throw new a$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=XQ($),Z=await j(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function XQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function KQ($,Q="--version"){if(!await f($))return null;let z=await j([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var a$;var d=L(()=>{a$=class a$ extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function a($){return WQ?"":$}var WQ,T,S,I,TZ,w,R,y,q;var c=L(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),I=a("\x1B[1;33m"),TZ=a("\x1B[0;34m"),w=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),q=a("\x1B[0m")});import{existsSync as TQ}from"fs";async function Q$(){if(B$!==void 0)return B$;let $="/opt/homebrew/bin/python3.12";if(TQ($))return B$=$,$;let Q=await f("python3.12");if(Q)return B$=Q,Q;let Z=await f("python3");return B$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return j([Z,"-c",$],Q)}var B$;var W$=L(()=>{d()});var t1={};h(t1,{runStatus:()=>mQ});import{existsSync as v,readFileSync as U$,readdirSync as l1,statSync as d1}from"fs";import{resolve as D,basename as xQ}from"path";import{homedir as NQ}from"os";async function DQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${q}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -789,4 +789,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
789
789
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
790
790
  `),process.stderr.write(o6),2}}p1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var ZZ=await QZ(Bun.argv.slice(2));process.exit(ZZ);
791
791
 
792
- //# debugId=98FDC89DEB1F99C464756E2164756E21
792
+ //# debugId=4C07FED5B6B5F4A164756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.48.0'
60
+ __version__ = '7.49.0'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.48.0",
4
+ "version": "7.49.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "7.48.0",
5
+ "version": "7.49.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",