loki-mode 7.48.0 → 7.50.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.
@@ -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.50.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.50.0 start ./my-spec.md
400
400
  ```
401
401
 
402
402
  ##### docker compose + .env (no host install)
@@ -72,6 +72,67 @@ loki syslog test --message "Test event from Loki Mode"
72
72
  tail -f /var/log/loki-mode.log
73
73
  ```
74
74
 
75
+ ## Event Export Module (CEF + Splunk HEC)
76
+
77
+ In addition to syslog forwarding, Loki Mode ships a programmatic exporter for
78
+ audit/security events at `src/observability/siem-export.js`. It provides two
79
+ well-specified, vendor-agnostic formats and an SSRF-safe HEC sender.
80
+
81
+ ### Zero egress unless configured
82
+
83
+ The module follows the same gate as the OTEL bridge: nothing leaves the host
84
+ unless an endpoint env var is set. `createHECSenderFromEnv()` returns `null`
85
+ when `LOKI_SPLUNK_HEC_URL` is unset, so there is no code path to the network.
86
+ Endpoint URLs are validated to be `http:`/`https:` only (the same SSRF guard
87
+ the OTLP exporter uses), so a stray `file://` or `gopher://` endpoint is
88
+ rejected before any request is built.
89
+
90
+ ### Auto-detected environment variables
91
+
92
+ | Variable | Required | Description |
93
+ |----------|----------|-------------|
94
+ | `LOKI_SPLUNK_HEC_URL` | enables HEC | Splunk HEC collector URL. Presence enables the sender. |
95
+ | `LOKI_SPLUNK_HEC_TOKEN` | no | HEC auth token (sent as `Authorization: Splunk <token>`). |
96
+ | `LOKI_SPLUNK_HEC_INDEX` | no | Target Splunk index. |
97
+ | `LOKI_SPLUNK_HEC_SOURCETYPE` | no | Sourcetype (default `loki:audit`). |
98
+ | `LOKI_CEF_VENDOR` / `LOKI_CEF_PRODUCT` | no | Override CEF vendor/product header fields. |
99
+
100
+ ### CEF (Common Event Format)
101
+
102
+ `toCEF(entry)` converts a Loki audit entry into a single-line CEF record.
103
+ Header pipes/backslashes and extension `=`/newlines are escaped per the CEF
104
+ spec, so events cannot break the record framing. Failed events
105
+ (`success: false`) are elevated to severity 8.
106
+
107
+ ```
108
+ CEF:0|Autonomi|Loki Mode|7.49.0|revoke_token|revoke_token|8|rt=2026-02-15T14:30:00.000Z suser=alice src=10.0.0.4 cs1=token cs1Label=resourceType outcome=failure msg=expired credential loki.provider=claude loki.cost=4.25
109
+ ```
110
+
111
+ Nested `details` are flattened under the `loki.` namespace. Standard CEF keys
112
+ are used where they exist (`rt`, `suser`, `src`, `outcome`, `msg`, `cs1..cs3`).
113
+
114
+ ### Splunk HEC JSON
115
+
116
+ `toHEC(entry)` wraps an audit entry in a Splunk HEC envelope (epoch-seconds
117
+ `time`, `sourcetype`, optional `index`, and the raw `event`). `HECSender.send()`
118
+ POSTs it fire-and-forget; network errors are logged, never thrown, so
119
+ observability never breaks the run.
120
+
121
+ ```bash
122
+ export LOKI_SPLUNK_HEC_URL=https://splunk.example.com:8088/services/collector
123
+ export LOKI_SPLUNK_HEC_TOKEN=your-hec-token
124
+ export LOKI_SPLUNK_HEC_INDEX=security
125
+ ```
126
+
127
+ ### GitHub Enterprise SAML SSO (docs-only follow-up)
128
+
129
+ Ingesting GitHub Enterprise SAML/SSO sign-in events is a documented follow-up,
130
+ not a shipped code path. Those events live in the GitHub audit log API and
131
+ require an org-scoped admin token plus an outbound polling client, which is out
132
+ of scope for the local audit path. Recommended approach today: pull the GitHub
133
+ Enterprise audit log via its native streaming to your SIEM (Splunk/Datadog/
134
+ Azure Event Hubs) and correlate on `actor`/`user_id` with Loki Mode events.
135
+
75
136
  ## Splunk Integration
76
137
 
77
138
  ### Method 1: Splunk Universal Forwarder
@@ -262,6 +323,47 @@ export LOKI_SYSLOG_FORMAT=cef
262
323
  # rt=2026-02-15T14:30:00Z suser=user cs1=claude cs1Label=Provider
263
324
  ```
264
325
 
326
+ ## OTEL Vendor Templates (Datadog, Honeycomb)
327
+
328
+ Loki Mode already emits OpenTelemetry traces/metrics when `LOKI_OTEL_ENDPOINT`
329
+ is set (see `src/observability/otel.js`). Ready-to-use vendor templates live in
330
+ `src/observability/siem-export.js` (`OTEL_TEMPLATES`) and produce the exact set
331
+ of env vars to ship to a vendor. They are recipes, not egress: copy the output
332
+ into your shell.
333
+
334
+ ### Datadog
335
+
336
+ Datadog ingests OTLP/HTTP via the Datadog Agent's OTLP receiver (default
337
+ `:4318`) or, agentless, via the OpenTelemetry Collector contrib exporter.
338
+
339
+ ```bash
340
+ # Local Datadog Agent OTLP receiver (recommended)
341
+ export LOKI_OTEL_ENDPOINT=http://localhost:4318
342
+ export LOKI_SERVICE_NAME=loki-mode
343
+
344
+ # Agentless intake (set API key + site)
345
+ export LOKI_OTEL_ENDPOINT=http://localhost:4318
346
+ export OTEL_EXPORTER_OTLP_HEADERS="dd-api-key=YOUR_DD_API_KEY"
347
+ export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,dd.site=datadoghq.com"
348
+ ```
349
+
350
+ `site` examples: `datadoghq.com`, `datadoghq.eu`, `us5.datadoghq.com`.
351
+
352
+ ### Honeycomb
353
+
354
+ Honeycomb ingests OTLP/HTTP directly. Auth is the `x-honeycomb-team` header.
355
+
356
+ ```bash
357
+ export LOKI_OTEL_ENDPOINT=https://api.honeycomb.io # or https://api.eu1.honeycomb.io
358
+ export LOKI_SERVICE_NAME=loki-mode
359
+ export OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=YOUR_API_KEY,x-honeycomb-dataset=loki"
360
+ ```
361
+
362
+ Note: `OTEL_EXPORTER_OTLP_HEADERS` is honored when the real `@opentelemetry`
363
+ SDK is installed (otel.js prefers it and falls back to the built-in JSON
364
+ exporter). For Datadog the local-agent path holds the API key, so the header is
365
+ optional there.
366
+
265
367
  ## Datadog Security Monitoring
266
368
 
267
369
  ### Log Collection