loki-mode 7.49.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.
@@ -453,11 +453,104 @@ verify_gate_static() {
453
453
  }
454
454
 
455
455
  # ---------------------------------------------------------------------------
456
- # Gate: secret scan (NET-NEW). gitleaks if available, otherwise a regex
457
- # fallback over the PR-diff files. A planted credential is a Critical finding.
456
+ # Gate: secret scan (NET-NEW).
457
+ #
458
+ # WHAT IS SCANNED:
459
+ # Only the files in the PR diff (merge-base(base,HEAD)..HEAD), i.e. the net
460
+ # change being verified. Pre-existing secrets in untouched files are NOT
461
+ # scanned by design: a verifier attests that THE CHANGE is safe, and blocking
462
+ # on legacy secrets is the #1 false-positive-fatigue failure (spec 7.2).
463
+ # Binary files are skipped.
464
+ #
465
+ # WHEN IT RUNS:
466
+ # As a gate inside `loki verify`, after generation/edits and BEFORE the change
467
+ # is treated as VERIFIED. A real secret here forces a non-VERIFIED verdict.
468
+ #
469
+ # GITLEAKS vs FALLBACK:
470
+ # - If `gitleaks` is on PATH, it scans each changed file (filesystem mode,
471
+ # `detect --no-git --source`). Any hit -> Critical finding for that file.
472
+ # - If gitleaks is NOT installed, a documented two-tier regex fallback runs
473
+ # (see verify_secret_scan_file). The fallback is a deterministic safety net,
474
+ # not a replacement for a full scanner; gitleaks is recommended for depth.
475
+ #
476
+ # BLOCKING GUARANTEE:
477
+ # Every detected secret is emitted as a Critical finding. The default
478
+ # --block-on is "critical,high" (verify_main), so verify_compute_verdict sets
479
+ # verdict=BLOCKED and exit=2 (VERIFY_EXIT_BLOCKED). The scan BLOCKS, it does
480
+ # not merely warn. (Operators who pass a narrower --block-on accept the risk.)
481
+ #
482
+ # FOLLOW-UP (deferred, honest): a true pre-WRITE hook that scans generated bytes
483
+ # BEFORE they touch disk would be stronger still, but it must hook every write
484
+ # path in run.sh (out of scope for this verify.sh slice). The gate here is the
485
+ # strong post-generation / pre-completion scan; the pre-write hook is tracked
486
+ # as a follow-up and is NOT claimed to exist.
458
487
  #
459
488
  # skipped = no changed files to scan.
460
489
  # ---------------------------------------------------------------------------
490
+
491
+ # Two-tier regex secret matcher for a single file. Echoes nothing; returns 0 if
492
+ # a high-confidence secret is found, 1 otherwise. Used ONLY by the fallback path
493
+ # (gitleaks absent). Kept as a standalone function so the test suite can drive it
494
+ # and so the two tiers are documented in one place.
495
+ #
496
+ # TIER 1 -- specific credential FORMATS. A match is conclusive on its own: the
497
+ # string already looks exactly like a real credential, so we do NOT apply the
498
+ # placeholder filter (a leaked key is a leak even if a comment says EXAMPLE).
499
+ #
500
+ # TIER 2 -- generic long quoted-value assignments (api_key="...", bearer
501
+ # tokens). This is a length + charset heuristic (>=16 contiguous secret-charset
502
+ # chars), NOT a true Shannon-entropy measure. These are false-positive magnets,
503
+ # so a match is only counted if the matched LINE survives the placeholder /
504
+ # env-reference deny filter. That keeps obvious non-secrets (your-api-key-here,
505
+ # REDACTED, ${API_KEY}, process.env.X) clean.
506
+ verify_secret_scan_file() {
507
+ local file="$1"
508
+
509
+ # TIER 1: specific formats. No deny filter -- a format match is a finding.
510
+ local tier1=(
511
+ 'AKIA[0-9A-Z]{16}' # AWS access key id
512
+ 'ASIA[0-9A-Z]{16}' # AWS temporary (STS) key id
513
+ '-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----' # PEM private key block
514
+ 'gh[pousr]_[A-Za-z0-9]{36,}' # GitHub token (ghp_/gho_/...)
515
+ 'github_pat_[A-Za-z0-9_]{60,}' # GitHub fine-grained PAT
516
+ 'xox[baprs]-[A-Za-z0-9-]{10,}' # Slack token (xoxb-/xoxp-/...)
517
+ 'sk-[A-Za-z0-9]{20,}' # OpenAI-style secret key
518
+ 'AIza[0-9A-Za-z_-]{35}' # Google API key
519
+ 'glpat-[A-Za-z0-9_-]{20,}' # GitLab personal access token
520
+ )
521
+ local p
522
+ for p in "${tier1[@]}"; do
523
+ # -e terminates option parsing so patterns beginning with '-' (the PEM
524
+ # "-----BEGIN ... PRIVATE KEY-----" block) are not mistaken for a flag.
525
+ if LC_ALL=C grep -Eq -e "$p" "$file" 2>/dev/null; then
526
+ return 0
527
+ fi
528
+ done
529
+
530
+ # Deny filter for TIER 2: a matched line is IGNORED if it is plainly a
531
+ # placeholder or an environment-variable reference rather than a literal.
532
+ # - env refs: ${VAR}, $VAR, process.env.X, os.environ/os.getenv, %VAR%
533
+ # - placeholders: your-/your_..., redacted, changeme, placeholder, example,
534
+ # dummy/sample/fake, xxxx+, <...>, runs of 4+ asterisks
535
+ # ("test" is deliberately NOT denied -- too common, would mask real keys)
536
+ local deny='(\$\{|\$[A-Za-z_]|process\.env|os\.(environ|getenv)|%[A-Za-z_]+%|your[-_]|redacted|changeme|change[-_]me|placeholder|example|dummy|sample|fake|<[^>]*>|x{4,}|\*{4,})'
537
+
538
+ # TIER 2: generic assignments. Grab matching lines, drop denied ones, count.
539
+ # api_key / apikey / secret / token / password / passwd / access_key, an
540
+ # assignment operator (= or :), then a quoted >=16-char high-entropy value.
541
+ local tier2='(api[_-]?key|secret|token|password|passwd|access[_-]?key|client[_-]?secret|auth)[A-Za-z0-9_]*[[:space:]]*[:=][[:space:]]*["'"'"']?[A-Za-z0-9_/+.=-]{16,}'
542
+ # Bearer tokens: "Bearer <>=20 high-entropy chars>".
543
+ local bearer='[Bb]earer[[:space:]]+[A-Za-z0-9_.\-]{20,}'
544
+
545
+ local surviving
546
+ surviving="$(LC_ALL=C grep -EiI "$tier2|$bearer" "$file" 2>/dev/null \
547
+ | LC_ALL=C grep -Eiv "$deny" 2>/dev/null)"
548
+ if [ -n "$surviving" ]; then
549
+ return 0
550
+ fi
551
+ return 1
552
+ }
553
+
461
554
  verify_gate_secret_scan() {
462
555
  local tree="$1"
463
556
  local changed="$VERIFY_DIFF_NAMES"
@@ -504,17 +597,10 @@ verify_gate_secret_scan() {
504
597
  return 0
505
598
  fi
506
599
 
507
- # High-confidence credential patterns. Conservative on purpose to limit
508
- # false positives (spec Section 7.2).
509
- local patterns=(
510
- 'AKIA[0-9A-Z]{16}' # AWS access key id
511
- '-----BEGIN [A-Z ]*PRIVATE KEY-----' # PEM private key
512
- 'gh[pousr]_[A-Za-z0-9]{36,}' # GitHub token
513
- 'xox[baprs]-[A-Za-z0-9-]{10,}' # Slack token
514
- 'sk-[A-Za-z0-9]{20,}' # OpenAI-style key
515
- 'AIza[0-9A-Za-z_-]{35}' # Google API key
516
- )
517
-
600
+ # High-confidence two-tier matcher (verify_secret_scan_file): tier 1 specific
601
+ # credential formats flag unconditionally; tier 2 generic assignments flag
602
+ # only when the line survives the placeholder/env-ref deny filter. Conservative
603
+ # on purpose to limit false positives (spec Section 7.2).
518
604
  local hits=0
519
605
  local detail=""
520
606
  local f
@@ -523,16 +609,12 @@ verify_gate_secret_scan() {
523
609
  [ -f "$tree/$f" ] || continue
524
610
  # Skip obvious binaries.
525
611
  if LC_ALL=C grep -Iq . "$tree/$f" 2>/dev/null; then : ; else continue; fi
526
- local p
527
- for p in "${patterns[@]}"; do
528
- if grep -Eq "$p" "$tree/$f" 2>/dev/null; then
529
- hits=$((hits + 1))
530
- detail="${detail}${f} "
531
- _verify_add_finding "Critical" "security" "deterministic:regex-secret-scan" "$f" "null" \
532
- "Potential hardcoded secret detected in $f (matched a high-confidence credential pattern)."
533
- break
534
- fi
535
- done
612
+ if verify_secret_scan_file "$tree/$f"; then
613
+ hits=$((hits + 1))
614
+ detail="${detail}${f} "
615
+ _verify_add_finding "Critical" "security" "deterministic:regex-secret-scan" "$f" "null" \
616
+ "Potential hardcoded secret detected in $f (matched a high-confidence credential pattern)."
617
+ fi
536
618
  done <<<"$changed"
537
619
 
538
620
  if [ "$hits" -eq 0 ]; then
@@ -975,9 +1057,9 @@ EOF
975
1057
  # Living-spec drift gate (integration with autonomy/spec.sh).
976
1058
  #
977
1059
  # When .loki/spec/spec.lock exists, source the spec module and run its
978
- # verify hook, which emits at most one SPEC_DRIFT finding (Medium -> CONCERNS)
979
- # in the verify finding TSV shape. Records a gate row either way so the
980
- # evidence document shows the spec was checked. Fully graceful: no lock, no
1060
+ # verify hook, which emits at most one SPEC_DRIFT finding (High -> BLOCKED when
1061
+ # the spec is locked) in the verify finding TSV shape. Records a gate row either
1062
+ # way so the evidence document shows the spec was checked. Fully graceful: no lock, no
981
1063
  # module, or a hook error all degrade to a skipped gate and never abort verify.
982
1064
  # ---------------------------------------------------------------------------
983
1065
  verify_spec_drift_gate() {
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.49.0"
10
+ __version__ = "7.50.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -513,30 +513,25 @@ def _file_has_integrity(log_file: str) -> bool:
513
513
  return False
514
514
 
515
515
 
516
- def verify_all_logs() -> dict:
517
- """v7.7.15: verify the entire audit chain across all rotated log files.
516
+ def verify_all_logs_in_dir(audit_dir) -> dict:
517
+ """Verify the entire audit chain across all rotated log files in
518
+ an explicit directory.
518
519
 
519
- Walks `AUDIT_DIR/audit-*.jsonl` in chronological order, threading
520
- the chain hash from one file to the next via `start_hash`. Skips
521
- files from the pre-integrity era (files whose first entry has no
522
- `_integrity_hash` field, because integrity hashing was introduced
523
- after some audit logs already existed).
520
+ This is the directory-parameterized core of :func:`verify_all_logs`.
521
+ The default :func:`verify_all_logs` delegates here with the module
522
+ ``AUDIT_DIR`` so existing callers are unaffected. The explicit-dir
523
+ form lets the unified cross-chain verifier (see ``src/audit/crosslink.js``)
524
+ validate an arbitrary audit directory without mutating module globals.
525
+
526
+ Args:
527
+ audit_dir: Path (str or pathlib.Path) to a directory containing
528
+ ``audit-*.jsonl`` files.
524
529
 
525
530
  Returns:
526
- A dict with:
527
- - valid (bool): True if the entire cross-file chain is intact.
528
- - files_checked (int): Count of integrity-bearing files inspected.
529
- - files_skipped (int): Count of pre-integrity files skipped.
530
- - entries_checked (int): Total entries verified across all files.
531
- - first_tampered_file (str | None): Path to the first file
532
- whose chain broke, or None if valid.
533
- - first_tampered_line (int | None): 1-based line number in
534
- that file where the chain broke, or None if valid.
535
- - genesis_file (str | None): Path to the first integrity-bearing
536
- log file (the chain's genesis on this machine), or None if
537
- no integrity-bearing files exist.
531
+ Same shape as :func:`verify_all_logs`.
538
532
  """
539
- if not AUDIT_DIR.exists():
533
+ audit_dir = Path(audit_dir)
534
+ if not audit_dir.exists():
540
535
  return {"valid": True, "files_checked": 0, "files_skipped": 0,
541
536
  "entries_checked": 0, "first_tampered_file": None,
542
537
  "first_tampered_line": None, "genesis_file": None}
@@ -547,7 +542,7 @@ def verify_all_logs() -> dict:
547
542
  # would break chain ordering and false-negative on any user who hit
548
543
  # size-based rotation. Sort by mtime instead -- mirrors what
549
544
  # `_cleanup_old_logs` already does at line 178.
550
- files = sorted(AUDIT_DIR.glob("audit-*.jsonl"), key=lambda p: p.stat().st_mtime)
545
+ files = sorted(audit_dir.glob("audit-*.jsonl"), key=lambda p: p.stat().st_mtime)
551
546
  prev_hash = "0" * 64
552
547
  total_entries = 0
553
548
  files_checked = 0
@@ -582,3 +577,189 @@ def verify_all_logs() -> dict:
582
577
  "first_tampered_line": None,
583
578
  "genesis_file": genesis_file,
584
579
  }
580
+
581
+
582
+ def verify_all_logs() -> dict:
583
+ """v7.7.15: verify the entire audit chain across all rotated log files.
584
+
585
+ Walks `AUDIT_DIR/audit-*.jsonl` in chronological order, threading
586
+ the chain hash from one file to the next via `start_hash`. Skips
587
+ files from the pre-integrity era (files whose first entry has no
588
+ `_integrity_hash` field, because integrity hashing was introduced
589
+ after some audit logs already existed).
590
+
591
+ Returns:
592
+ A dict with:
593
+ - valid (bool): True if the entire cross-file chain is intact.
594
+ - files_checked (int): Count of integrity-bearing files inspected.
595
+ - files_skipped (int): Count of pre-integrity files skipped.
596
+ - entries_checked (int): Total entries verified across all files.
597
+ - first_tampered_file (str | None): Path to the first file
598
+ whose chain broke, or None if valid.
599
+ - first_tampered_line (int | None): 1-based line number in
600
+ that file where the chain broke, or None if valid.
601
+ - genesis_file (str | None): Path to the first integrity-bearing
602
+ log file (the chain's genesis on this machine), or None if
603
+ no integrity-bearing files exist.
604
+ """
605
+ return verify_all_logs_in_dir(AUDIT_DIR)
606
+
607
+
608
+ def compute_chain_tip_in_dir(audit_dir) -> dict:
609
+ """Return the current tip (last hash) of the Python audit chain in
610
+ an explicit directory, plus a verification verdict for that chain.
611
+
612
+ Used by the unified cross-chain verifier to anchor the Python chain
613
+ state into the JS (``src/audit/log.js``) tamper-evident chain via a
614
+ cross-link record, and to reconcile a previously recorded anchor
615
+ against the live chain.
616
+
617
+ Args:
618
+ audit_dir: Path (str or pathlib.Path) to the Python audit dir.
619
+
620
+ Returns:
621
+ A dict with:
622
+ - genesis (str): The genesis hash for this chain ("0"*64).
623
+ - tip_hash (str): The last integrity hash, or the genesis hash
624
+ if the chain is empty.
625
+ - entries (int): Total integrity-bearing entries in the chain.
626
+ - valid (bool): Whether the chain verifies end-to-end.
627
+ - chain_id (str): Stable identifier for this chain family.
628
+ """
629
+ result = verify_all_logs_in_dir(audit_dir)
630
+ audit_dir = Path(audit_dir)
631
+ genesis = "0" * 64
632
+ tip_hash = genesis
633
+ if audit_dir.exists():
634
+ files = sorted(audit_dir.glob("audit-*.jsonl"), key=lambda p: p.stat().st_mtime)
635
+ prev = genesis
636
+ for log_file in files:
637
+ if not _file_has_integrity(str(log_file)):
638
+ continue
639
+ r = verify_log_integrity(str(log_file), start_hash=prev)
640
+ prev = r.get("last_hash", prev)
641
+ tip_hash = prev
642
+ return {
643
+ "genesis": genesis,
644
+ "tip_hash": tip_hash,
645
+ "entries": result.get("entries_checked", 0),
646
+ "valid": bool(result.get("valid", False)),
647
+ "chain_id": "loki-dashboard-audit",
648
+ }
649
+
650
+
651
+ def compute_prefix_hash_in_dir(audit_dir, n_entries: int) -> dict:
652
+ """Recompute the dashboard chain hash after exactly the first
653
+ ``n_entries`` integrity-bearing entries, walking files in mtime
654
+ order across rotations.
655
+
656
+ This is what lets the unified cross-chain verifier distinguish
657
+ legitimate append-only GROWTH from TAMPER. A cross-link anchor pins
658
+ ``(tip_hash, entries)`` at link time; later the live chain may have
659
+ grown. Reconciliation recomputes the hash of the first ``n_entries``
660
+ (the anchored prefix) and checks it still reproduces the anchored
661
+ ``tip_hash``. Growth keeps the prefix intact (reproducible); any
662
+ mutation at-or-before the anchor, or truncation below it, makes the
663
+ prefix unreproducible.
664
+
665
+ Args:
666
+ audit_dir: Path (str or pathlib.Path) to the Python audit dir.
667
+ n_entries: Number of leading integrity-bearing entries to hash.
668
+
669
+ Returns:
670
+ A dict with:
671
+ - found (bool): True if at least ``n_entries`` entries exist
672
+ and the prefix was hashed without a chain break inside it.
673
+ - prefix_hash (str): The chain hash after ``n_entries`` entries
674
+ (or the running hash reached if fewer entries exist / a break
675
+ occurred -- in which case ``found`` is False).
676
+ - entries_available (int): Total integrity-bearing entries seen.
677
+ """
678
+ audit_dir = Path(audit_dir)
679
+ genesis = "0" * 64
680
+ if n_entries <= 0:
681
+ return {"found": True, "prefix_hash": genesis, "entries_available": 0}
682
+ if not audit_dir.exists():
683
+ return {"found": False, "prefix_hash": genesis, "entries_available": 0}
684
+ files = sorted(audit_dir.glob("audit-*.jsonl"), key=lambda p: p.stat().st_mtime)
685
+ prev_hash = genesis
686
+ seen = 0
687
+ started = False
688
+ for log_file in files:
689
+ if not started and not _file_has_integrity(str(log_file)):
690
+ continue
691
+ started = True
692
+ try:
693
+ with open(log_file, "r") as f:
694
+ for line in f:
695
+ line = line.strip()
696
+ if not line:
697
+ continue
698
+ try:
699
+ entry = json.loads(line)
700
+ except json.JSONDecodeError:
701
+ return {"found": False, "prefix_hash": prev_hash,
702
+ "entries_available": seen}
703
+ stored_hash = entry.pop("_integrity_hash", None)
704
+ if stored_hash is None:
705
+ return {"found": False, "prefix_hash": prev_hash,
706
+ "entries_available": seen}
707
+ entry_json = json.dumps(entry, sort_keys=True, default=str)
708
+ expected = _compute_chain_hash(entry_json, prev_hash)
709
+ if stored_hash != expected:
710
+ # Chain broke inside the prefix -> not reproducible.
711
+ return {"found": False, "prefix_hash": prev_hash,
712
+ "entries_available": seen}
713
+ prev_hash = stored_hash
714
+ seen += 1
715
+ if seen == n_entries:
716
+ return {"found": True, "prefix_hash": prev_hash,
717
+ "entries_available": seen}
718
+ except OSError:
719
+ return {"found": False, "prefix_hash": prev_hash,
720
+ "entries_available": seen}
721
+ # Fewer than n_entries entries exist (truncation below the anchor).
722
+ return {"found": False, "prefix_hash": prev_hash, "entries_available": seen}
723
+
724
+
725
+ def _unified_cli() -> int:
726
+ """Tiny CLI shim so the Node-side unified verifier (or an operator)
727
+ can fetch the Python chain tip / verdict for a given directory as
728
+ JSON. Invoked as:
729
+
730
+ python3 dashboard/audit.py tip <audit_dir>
731
+ python3 dashboard/audit.py verify <audit_dir>
732
+
733
+ Prints a single JSON object to stdout. Returns process exit code 0
734
+ on a valid chain, 1 on an invalid chain, 2 on usage error.
735
+ """
736
+ argv = sys.argv[1:]
737
+ if len(argv) < 2 or argv[0] not in ("tip", "verify", "prefix"):
738
+ print(json.dumps(
739
+ {"error": "usage: audit.py {tip|verify} <audit_dir> "
740
+ "| prefix <audit_dir> <n_entries>"}))
741
+ return 2
742
+ cmd, audit_dir = argv[0], argv[1]
743
+ if cmd == "tip":
744
+ out = compute_chain_tip_in_dir(audit_dir)
745
+ print(json.dumps(out))
746
+ return 0 if out.get("valid", False) else 1
747
+ if cmd == "prefix":
748
+ if len(argv) < 3:
749
+ print(json.dumps({"error": "prefix requires <n_entries>"}))
750
+ return 2
751
+ try:
752
+ n = int(argv[2])
753
+ except ValueError:
754
+ print(json.dumps({"error": "n_entries must be an integer"}))
755
+ return 2
756
+ out = compute_prefix_hash_in_dir(audit_dir, n)
757
+ print(json.dumps(out))
758
+ return 0 if out.get("found", False) else 1
759
+ out = verify_all_logs_in_dir(audit_dir)
760
+ print(json.dumps(out))
761
+ return 0 if out.get("valid", False) else 1
762
+
763
+
764
+ if __name__ == "__main__":
765
+ sys.exit(_unified_cli())
@@ -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.49.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.49.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