loki-mode 7.129.5 → 8.0.1
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/README.md +121 -9
- package/SKILL.md +66 -5
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +37 -0
- package/autonomy/completion-council.sh +862 -28
- package/autonomy/council-v2.sh +39 -0
- package/autonomy/crash.sh +3 -2
- package/autonomy/grill.sh +42 -0
- package/autonomy/hooks/validate-bash.sh +301 -4
- package/autonomy/lib/cockpit-render.sh +8 -2
- package/autonomy/lib/cr-rematerialize.py +101 -4
- package/autonomy/lib/deadline.py +957 -0
- package/autonomy/lib/dependency-setup.sh +205 -0
- package/autonomy/lib/done-recognition.sh +45 -0
- package/autonomy/lib/efficiency_cost.py +13 -6
- package/autonomy/lib/no_mock_scan.py +793 -0
- package/autonomy/lib/prd-enrich.sh +42 -0
- package/autonomy/lib/proof-analytics-props.py +69 -0
- package/autonomy/lib/proof-generator.py +326 -92
- package/autonomy/lib/proof-template.html +15 -12
- package/autonomy/lib/proof-verify.py +169 -105
- package/autonomy/lib/requirements_contract.py +848 -0
- package/autonomy/lib/sdk-mode.sh +103 -0
- package/autonomy/lib/secret-scan.sh +139 -0
- package/autonomy/lib/spec-expand.sh +208 -0
- package/autonomy/lib/tree_digest.py +266 -0
- package/autonomy/lib/voter-agents.sh +58 -8
- package/autonomy/lib/workspace_diff.py +141 -0
- package/autonomy/loki +196 -17
- package/autonomy/playwright-verify.sh +501 -0
- package/autonomy/prd-checklist.sh +17 -8
- package/autonomy/provider-offer.sh +111 -0
- package/autonomy/run.sh +4587 -671
- package/autonomy/sandbox.sh +42 -0
- package/autonomy/spec-interrogation.sh +148 -5
- package/autonomy/spec.sh +118 -1
- package/autonomy/telemetry.sh +133 -7
- package/autonomy/verify.sh +107 -0
- package/bin/loki +110 -3
- package/completions/_loki +10 -0
- package/completions/loki.bash +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +96 -7
- package/dashboard/build_supervisor.py +1619 -0
- package/dashboard/control.py +8 -0
- package/dashboard/prompt_optimizer.py +7 -0
- package/dashboard/server.py +577 -22
- package/dashboard/static/trust.html +1 -1
- package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
- package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
- package/docs/INSTALLATION.md +19 -2
- package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
- package/docs/PLANS-INDEX.md +33 -0
- package/docs/PRIVACY.md +29 -1
- package/docs/SIGNED-RECEIPTS.md +102 -0
- package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
- package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
- package/docs/V8-AGENT-SDK-PLAN.md +692 -0
- package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
- package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
- package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
- package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
- package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
- package/docs/alternative-installations.md +4 -4
- package/loki-ts/dist/loki.js +674 -285
- package/loki-ts/package.json +2 -0
- package/mcp/__init__.py +1 -1
- package/package.json +7 -6
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +75 -0
- package/providers/codex.sh +85 -13
- package/references/sdk-mode.md +106 -0
- package/skills/model-selection.md +1 -1
- package/skills/providers.md +42 -0
- package/skills/quality-gates.md +22 -0
package/bin/loki
CHANGED
|
@@ -32,6 +32,19 @@ SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
|
|
|
32
32
|
REPO_ROOT=$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd)
|
|
33
33
|
BASH_CLI="$REPO_ROOT/autonomy/loki"
|
|
34
34
|
|
|
35
|
+
# v8.1: resolve the one-switch SDK mode (LOKI_SDK_MODE=off|judges|full) into the
|
|
36
|
+
# per-site LOKI_SDK_* flags HERE, in the outermost shim, BEFORE the `start`
|
|
37
|
+
# routing fork below reads LOKI_SDK_LOOP. Without this, `LOKI_SDK_MODE=full loki
|
|
38
|
+
# start` would run the judges on the SDK but silently keep the RARV loop on the
|
|
39
|
+
# legacy bash binary, because the routing decision predates autonomy/loki's own
|
|
40
|
+
# resolve. Same idempotent lib all three entrypoints source; exported flags reach
|
|
41
|
+
# the child (bash or Bun). No-op when the mode is unset.
|
|
42
|
+
if [ -f "$REPO_ROOT/autonomy/lib/sdk-mode.sh" ]; then
|
|
43
|
+
# shellcheck source=../autonomy/lib/sdk-mode.sh
|
|
44
|
+
source "$REPO_ROOT/autonomy/lib/sdk-mode.sh"
|
|
45
|
+
loki_sdk_resolve_mode
|
|
46
|
+
fi
|
|
47
|
+
|
|
35
48
|
# v7.89.0 telemetry TTY fix (council cH_r1): resolve interactivity EXACTLY ONCE
|
|
36
49
|
# here, while this shim still owns the user's real TTY, and export it as an
|
|
37
50
|
# explicit signal. The telemetry gates (telemetry.sh / crash.sh / dashboard
|
|
@@ -164,7 +177,7 @@ if [ -z "${LOKI_TELEMETRY_DISABLED:-}" ] && [ "${DO_NOT_TRACK:-}" != "1" ] && [
|
|
|
164
177
|
_loki_disclose_telemetry_once
|
|
165
178
|
# Fire-and-forget the installed event; detach all FDs (v7.8.3 broken-pipe fix).
|
|
166
179
|
( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null \
|
|
167
|
-
&& loki_telemetry "installed" "first_command=${1:-}" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
|
|
180
|
+
&& loki_telemetry "installed" "first_command=$(_loki_known_command "${1:-}")" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
|
|
168
181
|
disown 2>/dev/null || true
|
|
169
182
|
fi
|
|
170
183
|
fi
|
|
@@ -180,6 +193,45 @@ if [ "${LOKI_PROVIDER:-}" = "gemini" ]; then
|
|
|
180
193
|
exit 1
|
|
181
194
|
fi
|
|
182
195
|
|
|
196
|
+
# First-run funnel, stage C: the activation ATTEMPT (first `loki start`).
|
|
197
|
+
#
|
|
198
|
+
# WHY HERE. This shim runs for BOTH routes before any exec, so one emit at this
|
|
199
|
+
# point covers all four dispositions of a start: the Bun SDK fork, the
|
|
200
|
+
# needs-bash divert, LOKI_LEGACY_BASH, and the no-Bun fallthrough. An emit down
|
|
201
|
+
# in run.sh would be bash-route-only and would report installs climbing while
|
|
202
|
+
# starts sat near zero under LOKI_SDK_MODE=full, which is exactly the blind spot
|
|
203
|
+
# this is meant to close. Placed after the gemini guard (that path exits) and
|
|
204
|
+
# before the LOKI_LEGACY_BASH check (which execs).
|
|
205
|
+
#
|
|
206
|
+
# `run` is the deprecated start alias and `quick` is grouped with them in the
|
|
207
|
+
# bash config pre-pass, so all three count as an activation attempt.
|
|
208
|
+
#
|
|
209
|
+
# Gate: the strict EXISTING double gate, unchanged -- telemetry enabled AND
|
|
210
|
+
# analytics explicitly opted in (_loki_analytics_enabled), default OFF even for
|
|
211
|
+
# diagnostics-on users. No new consent knob, no new endpoint.
|
|
212
|
+
#
|
|
213
|
+
# Disclosure runs in the FOREGROUND first (the backgrounded emit has all three
|
|
214
|
+
# FDs detached, so a disclosure printed in there would go to /dev/null and the
|
|
215
|
+
# user would never see it), gated on the same analytics gate the egress uses so
|
|
216
|
+
# we never disclose for an egress that will not happen. This mirrors the
|
|
217
|
+
# cli_command sites below. No arguments are read, so nothing user-authored can
|
|
218
|
+
# reach the payload.
|
|
219
|
+
case "${1:-}" in
|
|
220
|
+
start|run|quick)
|
|
221
|
+
if command -v curl &>/dev/null && [ -f "$REPO_ROOT/autonomy/telemetry.sh" ]; then
|
|
222
|
+
if ( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null \
|
|
223
|
+
&& declare -f _loki_analytics_enabled >/dev/null 2>&1 && _loki_analytics_enabled \
|
|
224
|
+
&& [ ! -f "${HOME}/.loki/funnel-start-attempted" ] ); then
|
|
225
|
+
_loki_disclose_telemetry_once
|
|
226
|
+
fi
|
|
227
|
+
( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null \
|
|
228
|
+
&& loki_emit_funnel_once "start-attempted" "first_start_attempted" \
|
|
229
|
+
"entry=$(_loki_known_command "${1:-}")" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
|
|
230
|
+
disown 2>/dev/null || true
|
|
231
|
+
fi
|
|
232
|
+
;;
|
|
233
|
+
esac
|
|
234
|
+
|
|
183
235
|
# Force-fall-through to bash when the rollback flag is set.
|
|
184
236
|
if [ "${LOKI_LEGACY_BASH:-0}" = "1" ] || [ "${LOKI_LEGACY_BASH:-}" = "true" ]; then
|
|
185
237
|
exec "$BASH_CLI" "$@"
|
|
@@ -191,6 +243,61 @@ if ! command -v bun &>/dev/null; then
|
|
|
191
243
|
exec "$BASH_CLI" "$@"
|
|
192
244
|
fi
|
|
193
245
|
|
|
246
|
+
# v8 Phase 4: route `start` to the Bun RARV loop ONLY when LOKI_SDK_LOOP is on.
|
|
247
|
+
# Default (unset) keeps `loki start` on the bash cmd_start, byte-identical. This
|
|
248
|
+
# guard is AFTER the legacy check (LOKI_LEGACY_BASH=1 still force-overrides to
|
|
249
|
+
# bash above) and AFTER the bun-presence check (a bun-less host already fell to
|
|
250
|
+
# bash). Accepts 1|true to match the flag's documented =1 usage; bash cannot
|
|
251
|
+
# cheaply reproduce truthy()'s yes/on spellings, so those are not honored here
|
|
252
|
+
# (documented divergence, same as other bin/loki bool guards). v8.1: LOKI_SDK_LOOP
|
|
253
|
+
# may also be set by the mode resolver above (LOKI_SDK_MODE=full -> LOOP=1), so
|
|
254
|
+
# `LOKI_SDK_MODE=full loki start` now correctly routes the loop to Bun here.
|
|
255
|
+
#
|
|
256
|
+
# RUN-25 iter 2 (T3(c) pre-flight): the Bun runAutonomous loop handles the RARV
|
|
257
|
+
# loop only. Orchestration flags that do pre-loop work the Bun loop does NOT do
|
|
258
|
+
# (git-worktree parallel streams, GitHub/issue import, Docker sandbox, the API
|
|
259
|
+
# server, backgrounding, alternate spec sources) must run on the bash route so the
|
|
260
|
+
# user gets FULL behavior even with the loop flag on. Scan the start args for any
|
|
261
|
+
# such flag (or an issue-ref positional) and, if present, DIVERT back to bash
|
|
262
|
+
# instead of taking the Bun fork. This is the "pre-loop handled, not rejected"
|
|
263
|
+
# guarantee that makes the eventual loop default-flip a no-regression. Runner
|
|
264
|
+
# flags (--simple, --allow-haiku, --regen-prd, --skip-memory, provider knobs) are
|
|
265
|
+
# handled by start.ts and do NOT divert. See artifacts/ITER1-FLAG-RECONCILE.md.
|
|
266
|
+
_loki_start_needs_bash() {
|
|
267
|
+
# $@ = the start args (already know $1 = start). Return 0 (divert to bash) if
|
|
268
|
+
# any bash-only orchestration flag or an issue-ref spec is present.
|
|
269
|
+
local a
|
|
270
|
+
for a in "$@"; do
|
|
271
|
+
case "$a" in
|
|
272
|
+
--parallel|--bg|--background|--github|--api|--sandbox \
|
|
273
|
+
|--bmad-project|--openspec \
|
|
274
|
+
|--mirofish|--mirofish-docker|--mirofish-rounds|--mirofish-timeout|--mirofish-bg \
|
|
275
|
+
|--issue|--dry-run|--no-start|--output|--worktree|-w|--pr|--ship|--detach|-d)
|
|
276
|
+
return 0 ;;
|
|
277
|
+
--isolation)
|
|
278
|
+
# value follows; only worktree/docker/sandbox divert (none stays Bun).
|
|
279
|
+
_loki_iso_next=1 ;;
|
|
280
|
+
worktree|wt|docker|sandbox)
|
|
281
|
+
[ "${_loki_iso_next:-0}" = "1" ] && { unset _loki_iso_next; return 0; }
|
|
282
|
+
_loki_iso_next=0 ;;
|
|
283
|
+
*)
|
|
284
|
+
# issue-ref positional: owner/repo#N, a tracker URL, or *#<digits>.
|
|
285
|
+
case "$a" in
|
|
286
|
+
*/*\#[0-9]*|http*://*/issues/*|http*://*/browse/*) return 0 ;;
|
|
287
|
+
esac
|
|
288
|
+
_loki_iso_next=0 ;;
|
|
289
|
+
esac
|
|
290
|
+
done
|
|
291
|
+
unset _loki_iso_next
|
|
292
|
+
return 1
|
|
293
|
+
}
|
|
294
|
+
if [ "${1:-}" = "start" ] && { [ "${LOKI_SDK_LOOP:-}" = "1" ] || [ "${LOKI_SDK_LOOP:-}" = "true" ]; }; then
|
|
295
|
+
if _loki_start_needs_bash "${@:2}"; then
|
|
296
|
+
exec "$BASH_CLI" "$@"
|
|
297
|
+
fi
|
|
298
|
+
exec bun "$BUN_CLI" "$@"
|
|
299
|
+
fi
|
|
300
|
+
|
|
194
301
|
# CLI consolidation (Phase A): `trust detail` is the grouped form of the
|
|
195
302
|
# trust-metrics breakdown. The Bun `trust` handler only knows the trajectory
|
|
196
303
|
# view (it rejects unknown args), and Phase A moves no handler logic, so the
|
|
@@ -249,7 +356,7 @@ if [ "${1:-}" = "report" ]; then
|
|
|
249
356
|
&& declare -f _loki_telemetry_enabled >/dev/null 2>&1 && _loki_telemetry_enabled ); then
|
|
250
357
|
_loki_disclose_telemetry_once
|
|
251
358
|
fi
|
|
252
|
-
( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null && loki_telemetry "cli_command" "command=${1:-}" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
|
|
359
|
+
( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null && loki_telemetry "cli_command" "command=$(_loki_known_command "${1:-}")" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
|
|
253
360
|
disown 2>/dev/null || true
|
|
254
361
|
fi
|
|
255
362
|
exec bun "$BUN_CLI" "$@"
|
|
@@ -291,7 +398,7 @@ case "${1:-}" in
|
|
|
291
398
|
&& declare -f _loki_telemetry_enabled >/dev/null 2>&1 && _loki_telemetry_enabled ); then
|
|
292
399
|
_loki_disclose_telemetry_once
|
|
293
400
|
fi
|
|
294
|
-
( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null && loki_telemetry "cli_command" "command=${1:-}" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
|
|
401
|
+
( SCRIPT_DIR="$REPO_ROOT/autonomy"; source "$SCRIPT_DIR/telemetry.sh" 2>/dev/null && loki_telemetry "cli_command" "command=$(_loki_known_command "${1:-}")" 2>/dev/null ) >/dev/null 2>&1 </dev/null &
|
|
295
402
|
disown 2>/dev/null || true
|
|
296
403
|
fi
|
|
297
404
|
exec bun "$BUN_CLI" "$@"
|
package/completions/_loki
CHANGED
|
@@ -126,10 +126,12 @@ function _loki_commands {
|
|
|
126
126
|
'monitor:Monitor Docker Compose services with auto-fix'
|
|
127
127
|
'demo:Interactive 60-second demo'
|
|
128
128
|
'tour:Offline sample receipt replay (no key, no spend)'
|
|
129
|
+
'welcome:Friendly first-run opener (what Loki is, next steps)'
|
|
129
130
|
'init:Interactive PRD builder'
|
|
130
131
|
'stop:Stop execution'
|
|
131
132
|
'pause:Pause execution'
|
|
132
133
|
'resume:Resume execution'
|
|
134
|
+
'steer:Steer a running build with a note (needs LOKI_PROMPT_INJECTION=1)'
|
|
133
135
|
'status:Show status'
|
|
134
136
|
'next:Run the right next step for the current build'
|
|
135
137
|
'ship:Finish the build (gates + PR advice) in one command'
|
|
@@ -144,18 +146,26 @@ function _loki_commands {
|
|
|
144
146
|
'notify:Notification commands'
|
|
145
147
|
'import:Import GitHub issues'
|
|
146
148
|
'issue:GitHub issue commands'
|
|
149
|
+
'github:GitHub integration commands'
|
|
147
150
|
'config:Config commands'
|
|
148
151
|
'provider:Provider commands'
|
|
149
152
|
'reset:Reset session state'
|
|
150
153
|
'memory:Memory commands'
|
|
151
154
|
'compound:Knowledge compounding commands'
|
|
155
|
+
'checkpoint:Snapshot and restore session state'
|
|
152
156
|
'council:Completion council commands'
|
|
153
157
|
'dogfood:Self-development statistics'
|
|
154
158
|
'projects:Project registry commands'
|
|
155
159
|
'enterprise:Enterprise feature commands'
|
|
160
|
+
'secrets:Secrets and API key management'
|
|
161
|
+
'cockpit:Live cockpit view of a running build'
|
|
162
|
+
'secure:Secure-by-default gate (findings and waivers)'
|
|
163
|
+
'own:Finish-and-own ownership handoff'
|
|
156
164
|
'telemetry:OpenTelemetry management'
|
|
157
165
|
'agent:Agent type dispatch'
|
|
158
166
|
'doctor:Check system prerequisites'
|
|
167
|
+
'watchdog:Watchdog process supervision'
|
|
168
|
+
'docs:Documentation commands'
|
|
159
169
|
'onboard:Analyze repo and generate CLAUDE.md'
|
|
160
170
|
'metrics:Session productivity report'
|
|
161
171
|
'share:Share session report as GitHub Gist'
|
package/completions/loki.bash
CHANGED
|
@@ -5,7 +5,7 @@ _loki_completion() {
|
|
|
5
5
|
_init_completion || return
|
|
6
6
|
|
|
7
7
|
# Main subcommands (must match autonomy/loki main case statement)
|
|
8
|
-
local main_commands="start quick monitor demo tour init stop pause resume status next ship dashboard web serve api sandbox notify import github issue config provider reset memory compound checkpoint council dogfood projects enterprise secrets doctor watchdog audit metrics syslog onboard share proof explain plan report cost kpis stats test ci watch telemetry agent context ctx code run export review optimize heal modernize migrate cluster worktree wt trigger failover remote deploy docker mcp magic assets analyze compliance crash open otel preview quickstart rc rollback self-update sentrux setup-skill spec state template trust trust-metrics ultracode update verify voice why wiki bench cleanup logs grill docs cp version completions help"
|
|
8
|
+
local main_commands="start quick monitor demo tour welcome init stop pause resume steer status next ship dashboard web serve api sandbox notify import github issue config provider reset memory compound checkpoint council dogfood projects enterprise secrets cockpit secure own doctor watchdog audit metrics syslog onboard share proof explain plan report cost kpis stats test ci watch telemetry agent context ctx code run export review optimize heal modernize migrate cluster worktree wt trigger failover remote deploy docker mcp magic assets analyze compliance crash open otel preview quickstart rc rollback self-update sentrux setup-skill spec state template trust trust-metrics ultracode update verify voice why wiki bench cleanup logs grill docs cp version completions help"
|
|
9
9
|
|
|
10
10
|
# 1. If we are on the first argument (subcommand)
|
|
11
11
|
if [[ $cword -eq 1 ]]; then
|
package/dashboard/__init__.py
CHANGED
package/dashboard/audit.py
CHANGED
|
@@ -22,6 +22,15 @@ import os
|
|
|
22
22
|
import socket
|
|
23
23
|
import sys
|
|
24
24
|
import threading
|
|
25
|
+
|
|
26
|
+
# POSIX-only. Used to serialize the tamper-evident chain append ACROSS PROCESSES
|
|
27
|
+
# (threading.Lock cannot). Imported defensively so this module still loads on a
|
|
28
|
+
# platform without it; the writer degrades to thread-only serialization rather
|
|
29
|
+
# than refusing to record an audit entry.
|
|
30
|
+
try:
|
|
31
|
+
import fcntl
|
|
32
|
+
except ImportError: # pragma: no cover - non-POSIX
|
|
33
|
+
fcntl = None # type: ignore[assignment]
|
|
25
34
|
from datetime import datetime, timezone
|
|
26
35
|
from pathlib import Path
|
|
27
36
|
from typing import Any, Optional
|
|
@@ -59,6 +68,40 @@ _last_hash: str = "0" * 64 # Genesis hash
|
|
|
59
68
|
_hash_lock = threading.Lock()
|
|
60
69
|
|
|
61
70
|
|
|
71
|
+
def _tail_chain_hash(fh) -> "str | None":
|
|
72
|
+
"""Return the _integrity_hash of the LAST well-formed line in an open log.
|
|
73
|
+
|
|
74
|
+
Called while holding the exclusive flock, so what it reads is the true chain
|
|
75
|
+
tip on disk rather than whatever this process last remembered. That is the
|
|
76
|
+
whole point: a second writer process must chain from the first writer's
|
|
77
|
+
entry, not from its own frozen import-time tip.
|
|
78
|
+
|
|
79
|
+
Returns None when there is nothing to chain from -- an empty file, or a tail
|
|
80
|
+
this function cannot parse. None means "no opinion", and the caller keeps its
|
|
81
|
+
existing _last_hash. It deliberately does NOT return a genesis or an empty
|
|
82
|
+
string on a parse failure: inventing a tip is how a chain silently re-roots,
|
|
83
|
+
which reads downstream as tampering. Scanning backwards means a torn final
|
|
84
|
+
line (a process killed mid-append) is skipped rather than treated as the tip.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
fh.seek(0)
|
|
88
|
+
lines = fh.read().splitlines()
|
|
89
|
+
except (OSError, UnicodeDecodeError):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
for raw in reversed(lines):
|
|
93
|
+
raw = raw.strip()
|
|
94
|
+
if not raw:
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
h = json.loads(raw).get("_integrity_hash")
|
|
98
|
+
except (ValueError, AttributeError):
|
|
99
|
+
continue # torn or foreign line; keep looking back
|
|
100
|
+
if isinstance(h, str) and h:
|
|
101
|
+
return h
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
62
105
|
def _recover_last_hash() -> str:
|
|
63
106
|
"""Recover the last integrity hash from the most recent audit log file.
|
|
64
107
|
|
|
@@ -281,16 +324,62 @@ def log_event(
|
|
|
281
324
|
# change and is intentionally not done here.
|
|
282
325
|
global _last_hash
|
|
283
326
|
with _hash_lock:
|
|
284
|
-
if INTEGRITY_ENABLED:
|
|
285
|
-
entry_json = json.dumps(entry, sort_keys=True, default=str)
|
|
286
|
-
entry["_integrity_hash"] = _compute_chain_hash(entry_json, _last_hash)
|
|
287
|
-
_last_hash = entry["_integrity_hash"]
|
|
288
|
-
|
|
289
327
|
log_file = _get_current_log_file()
|
|
290
328
|
_rotate_logs_if_needed(log_file)
|
|
291
329
|
|
|
292
|
-
|
|
293
|
-
|
|
330
|
+
if not INTEGRITY_ENABLED:
|
|
331
|
+
with open(log_file, "a") as f:
|
|
332
|
+
f.write(json.dumps(entry) + "\n")
|
|
333
|
+
_forward_to_syslog(entry)
|
|
334
|
+
return entry
|
|
335
|
+
|
|
336
|
+
# CROSS-PROCESS serialization. _hash_lock is a threading.Lock, which has
|
|
337
|
+
# no meaning across processes, and _last_hash is resolved ONCE at import
|
|
338
|
+
# (see the _recover_last_hash call near the top of this module). Together
|
|
339
|
+
# those meant every concurrently-live writer PROCESS chained from its own
|
|
340
|
+
# frozen tip, silently forking the chain at write time with no tampering
|
|
341
|
+
# involved. Measured on a real machine: 25 of 67 audit files internally
|
|
342
|
+
# chain-broken, ~10k entries, spanning months and still occurring.
|
|
343
|
+
#
|
|
344
|
+
# That is not a cosmetic bug. This chain is the tamper-evidence: a
|
|
345
|
+
# verifier cannot distinguish "two writers raced" from "someone edited
|
|
346
|
+
# the log", so a self-inflicted fork burns the very signal the chain
|
|
347
|
+
# exists to provide. It fails LOUD rather than silent, which is the safe
|
|
348
|
+
# direction, but it destroys the guarantee either way.
|
|
349
|
+
#
|
|
350
|
+
# Fix: take an exclusive flock on the log file and re-derive the true
|
|
351
|
+
# tail INSIDE it, so the tip we chain from is whatever actually landed on
|
|
352
|
+
# disk, not what this process remembered. Best effort by design -- a
|
|
353
|
+
# platform without flock, or a filesystem that refuses it, degrades to
|
|
354
|
+
# the previous behavior rather than dropping the audit record. Losing an
|
|
355
|
+
# audit entry is worse than a fork.
|
|
356
|
+
with open(log_file, "a+") as f:
|
|
357
|
+
locked = False
|
|
358
|
+
try:
|
|
359
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
360
|
+
locked = True
|
|
361
|
+
except (OSError, AttributeError, NameError):
|
|
362
|
+
pass # no flock here; fall through unserialized
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
if locked:
|
|
366
|
+
tail = _tail_chain_hash(f)
|
|
367
|
+
if tail is not None:
|
|
368
|
+
_last_hash = tail
|
|
369
|
+
|
|
370
|
+
entry_json = json.dumps(entry, sort_keys=True, default=str)
|
|
371
|
+
entry["_integrity_hash"] = _compute_chain_hash(entry_json, _last_hash)
|
|
372
|
+
_last_hash = entry["_integrity_hash"]
|
|
373
|
+
|
|
374
|
+
f.seek(0, os.SEEK_END)
|
|
375
|
+
f.write(json.dumps(entry) + "\n")
|
|
376
|
+
f.flush()
|
|
377
|
+
finally:
|
|
378
|
+
if locked:
|
|
379
|
+
try:
|
|
380
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
381
|
+
except OSError:
|
|
382
|
+
pass
|
|
294
383
|
|
|
295
384
|
# Forward to syslog if configured (outside the lock: fire-and-forget and
|
|
296
385
|
# must never extend the critical section / block other writers).
|