loki-mode 9.54.0 → 9.55.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/README.md +1 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +120 -35
- package/autonomy/crash.sh +6 -6
- package/autonomy/lib/done-recognition.sh +7 -0
- package/autonomy/lib/prd-enrich.sh +7 -0
- package/autonomy/lib/proof-generator.py +36 -5
- package/autonomy/lib/proof-verify.py +93 -21
- package/autonomy/lib/voter-agents.sh +7 -0
- package/autonomy/loki +404 -119
- package/autonomy/quickstart.sh +7 -0
- package/autonomy/run.sh +836 -58
- package/autonomy/verify.sh +114 -8
- package/bin/loki +12 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_v2.py +4 -0
- package/dashboard/audit.py +9 -1
- package/dashboard/auth.py +16 -4
- package/dashboard/registry.py +34 -12
- package/dashboard/rigour_integration.py +25 -17
- package/dashboard/server.py +252 -104
- package/dashboard/static/cost.html +55 -26
- package/dashboard/static/index.html +475 -1544
- package/dashboard/static/proofs.html +1 -1
- package/docs/INSTALLATION.md +7 -6
- package/docs/air-gapped.md +38 -11
- package/docs/enterprise/security.md +10 -13
- package/docs/environment-variables.md +1 -0
- package/docs/exit-codes.md +76 -2
- package/docs/image-provenance.md +4 -2
- package/docs/v10/BACKLOG.md +64 -2
- package/docs/v10/BOARD.md +134 -0
- package/docs/v10/CONTROL.md +36 -0
- package/docs/v10/DECISIONS.md +102 -0
- package/docs/v10/METRICS.md +78 -1
- package/docs/v10/PROGRESS.md +240 -0
- package/docs/v10/SWARM-PROMPT.md +98 -0
- package/docs/v10/SWARM.md +237 -0
- package/loki-ts/dist/loki.js +7 -4
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/aider.sh +8 -0
- package/providers/cline.sh +8 -0
- package/providers/codex.sh +9 -0
- package/providers/loader.sh +10 -0
- package/providers/opencode.sh +9 -0
- package/tools/receipt-attest.py +1 -1
- package/web-app/dist/assets/AdminPage-CZrBkzRF.js +32 -0
- package/web-app/dist/assets/Avatar-gQIp6FBI.js +11 -0
- package/web-app/dist/assets/{Badge-BF3mhT57.js → Badge-D2_fBTFY.js} +1 -1
- package/web-app/dist/assets/{Button-BDp9aJ9q.js → Button-D5qDd2ef.js} +1 -1
- package/web-app/dist/assets/CockpitPage-DVMk2RUK.js +47 -0
- package/web-app/dist/assets/ComparePage-NMLyE1xL.js +1 -0
- package/web-app/dist/assets/{ErrorBoundary-ut3wrUmu.js → ErrorBoundary-BRABI-BT.js} +1 -1
- package/web-app/dist/assets/EvidenceReceiptPanel-D2LNKqUx.js +1 -0
- package/web-app/dist/assets/GitHubIssuesPanel-BC5GzKUN.js +7 -0
- package/web-app/dist/assets/{GitHubPRsPanel-DurZgSaj.js → GitHubPRsPanel-BSlmoVTS.js} +3 -3
- package/web-app/dist/assets/HomePage-D_CDMoen.js +12 -0
- package/web-app/dist/assets/{LoginPage-HeRGJeZy.js → LoginPage-BBNA_N9t.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CUKrqsZs.js → MagicPage-CnFRZCMx.js} +1 -1
- package/web-app/dist/assets/MetricsPage-CPfLMRlh.js +6 -0
- package/web-app/dist/assets/{NotFoundPage-BZDZ-B77.js → NotFoundPage-lA0XSGTz.js} +1 -1
- package/web-app/dist/assets/ProjectPage-CNAPbr9z.js +256 -0
- package/web-app/dist/assets/ProjectsPage-CMtm8goN.js +6 -0
- package/web-app/dist/assets/SettingsPage-BhIjVMYE.js +10 -0
- package/web-app/dist/assets/{ShowcasePage-BGS2jTGH.js → ShowcasePage-BndtGeYo.js} +1 -1
- package/web-app/dist/assets/SystemSettingsPage-euXI-KXE.js +1 -0
- package/web-app/dist/assets/TeamsPage-ClUKecun.js +11 -0
- package/web-app/dist/assets/TemplatesPage-BWHWVk0L.js +29 -0
- package/web-app/dist/assets/{TerminalOutput-togzv9Zl.js → TerminalOutput-Cv3N3Szf.js} +1 -1
- package/web-app/dist/assets/{activity-Dreyb_Kr.js → activity-dEhz-bXR.js} +1 -1
- package/web-app/dist/assets/{bell-DWH0drES.js → bell-B3XUMq1t.js} +1 -1
- package/web-app/dist/assets/{bot-78MoCuDl.js → bot-X-ssNlwR.js} +1 -1
- package/web-app/dist/assets/{check-ikfylPst.js → check-C_9Nx7kb.js} +1 -1
- package/web-app/dist/assets/{chevron-left-DXUqkd8J.js → chevron-left-tvNswo3A.js} +1 -1
- package/web-app/dist/assets/{circle-alert-Da74VO8P.js → circle-alert-CSB88nEs.js} +1 -1
- package/web-app/dist/assets/{clock-mlGCFq8l.js → clock-C8p2e4NI.js} +1 -1
- package/web-app/dist/assets/{cloud-B_Y5ZrIO.js → cloud-CKp2ZvhG.js} +1 -1
- package/web-app/dist/assets/{code-xml-Bbom7YWV.js → code-xml-dY70LCHD.js} +1 -1
- package/web-app/dist/assets/{database-CLcofiVp.js → database-DObn5fmn.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-C5mkjXIM.js → dollar-sign-DSu6n3K3.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-CVrkaOQP.js → file-code-corner-DpCnS73z.js} +1 -1
- package/web-app/dist/assets/{file-plus-BV9240gX.js → file-plus-D3IPKCf1.js} +1 -1
- package/web-app/dist/assets/{globe-aZ0Zso4I.js → globe-C9QXkgH_.js} +1 -1
- package/web-app/dist/assets/{hammer-EgOQNukG.js → hammer-D_FtWe1y.js} +1 -1
- package/web-app/dist/assets/index-CI54rara.css +1 -0
- package/web-app/dist/assets/{index-DvZ0Kk2x.js → index-Cz7q1bvC.js} +2 -2
- package/web-app/dist/assets/{layers-BOVKmepE.js → layers-DsW7jnJX.js} +1 -1
- package/web-app/dist/assets/{loader-circle-0K8yLgHl.js → loader-circle-B3HspjKR.js} +1 -1
- package/web-app/dist/assets/{lock-XzlDMy8M.js → lock-DSI29pct.js} +1 -1
- package/web-app/dist/assets/{package-BtXTKusI.js → package-CLNxtA2x.js} +1 -1
- package/web-app/dist/assets/{plus-PxeiD8ju.js → plus-DyI4WC4_.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-CjqzYczt.js → refresh-cw-D2dv0xA0.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CY0os4DH.js → rotate-ccw-CBvihA-a.js} +1 -1
- package/web-app/dist/assets/scroll-text-DvoOvAl_.js +6 -0
- package/web-app/dist/assets/{server-pIIs9vNp.js → server-DenWvSUt.js} +1 -1
- package/web-app/dist/assets/{shield-alert-CsDqKXwA.js → shield-alert-40cX_FvG.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-CAh3HVzH.js → thumbs-up-BrjZ6QuV.js} +12 -7
- package/web-app/dist/assets/trash-2-C6evwkEH.js +16 -0
- package/web-app/dist/assets/{trending-down-DrtBd_C9.js → trending-down-CfbZis3O.js} +1 -1
- package/web-app/dist/assets/{trending-up-D-DbCHXm.js → trending-up-DWuEK3WC.js} +1 -1
- package/web-app/dist/assets/{upload-D4l6DCOt.js → upload-B9DFBcPG.js} +1 -1
- package/web-app/dist/assets/{usePolling-D0aoTX80.js → usePolling-BryVC-T1.js} +1 -1
- package/web-app/dist/assets/{user-BQf_XJO_.js → user-DMRXwZiw.js} +1 -1
- package/web-app/dist/index.html +2 -2
- package/web-app/server.py +122 -47
- package/web-app/dist/assets/AdminPage-y6_b2Xo4.js +0 -32
- package/web-app/dist/assets/Avatar-BFPkXhIJ.js +0 -6
- package/web-app/dist/assets/CockpitPage-5TKICCoA.js +0 -47
- package/web-app/dist/assets/ComparePage-BSuNsUHy.js +0 -1
- package/web-app/dist/assets/EvidenceReceiptPanel-CcaBwEI-.js +0 -1
- package/web-app/dist/assets/GitHubIssuesPanel-DJeYgzAa.js +0 -12
- package/web-app/dist/assets/HomePage-Bs451LAy.js +0 -28
- package/web-app/dist/assets/MetricsPage-DvY1csgz.js +0 -6
- package/web-app/dist/assets/ProjectPage-CqHkEZkl.js +0 -257
- package/web-app/dist/assets/ProjectsPage-CLvCK1Y8.js +0 -6
- package/web-app/dist/assets/SettingsPage-DP7OmdwP.js +0 -10
- package/web-app/dist/assets/SystemSettingsPage-P_rlLh0x.js +0 -1
- package/web-app/dist/assets/TeamsPage-DxtBDcBC.js +0 -11
- package/web-app/dist/assets/TemplatesPage-5e-faH3W.js +0 -29
- package/web-app/dist/assets/folder-open-D_6yV78G.js +0 -11
- package/web-app/dist/assets/index-BSgmPwC0.css +0 -1
- package/web-app/dist/assets/lightbulb-DdASgqOG.js +0 -6
- package/web-app/dist/assets/mail-COu670_W.js +0 -6
- package/web-app/dist/assets/minus-bQpUt9ff.js +0 -6
- package/web-app/dist/assets/save-v8qep5cY.js +0 -6
- package/web-app/dist/assets/trash-2-Dt6urTyY.js +0 -6
package/autonomy/verify.sh
CHANGED
|
@@ -1689,14 +1689,26 @@ verify_gate_runtime() {
|
|
|
1689
1689
|
local boot_log="$artifact_dir/boot.log"
|
|
1690
1690
|
|
|
1691
1691
|
# Boot the app in the background, bounded by the timeout wrapper. The whole
|
|
1692
|
-
# process group is killed on teardown. Env is inherited (no secrets injected)
|
|
1693
|
-
#
|
|
1694
|
-
#
|
|
1695
|
-
# it
|
|
1692
|
+
# process group is killed on teardown. Env is inherited (no secrets injected)
|
|
1693
|
+
# PLUS one new marker: LOKI_VERIFY_RUN_TOKEN, a per-run random value used
|
|
1694
|
+
# ONLY as an ownership proof at teardown (see _verify_runtime_teardown) --
|
|
1695
|
+
# it identifies "a process this exact verify run launched" even after a
|
|
1696
|
+
# setsid()'d daemon detaches into its own session/process group, which
|
|
1697
|
+
# defeats every pgid/ppid-based check (round-4 council finding: v7.109.0's
|
|
1698
|
+
# daemonize-leak fixture uses child_process.spawn({detached:true}), and no
|
|
1699
|
+
# pgid/ppid arm can ever match a setsid'd process). Environment variables
|
|
1700
|
+
# survive setsid() and fork()/spawn() regardless of process-group changes,
|
|
1701
|
+
# so this closes exactly the gap those checks cannot. PORT is exported to
|
|
1702
|
+
# the resolved port so 12-factor apps (node, many python frameworks) listen
|
|
1703
|
+
# where the probe looks; apps that hardcode a port ignore it harmlessly.
|
|
1704
|
+
# This keeps boot-port and probe-port consistent.
|
|
1705
|
+
local run_token=""
|
|
1706
|
+
run_token="verify-$$-${RANDOM}-${RANDOM}-$(date +%s 2>/dev/null || echo 0)"
|
|
1696
1707
|
local app_pid=""
|
|
1697
1708
|
(
|
|
1698
1709
|
cd "$tree" || exit 127
|
|
1699
1710
|
export PORT="$port"
|
|
1711
|
+
export LOKI_VERIFY_RUN_TOKEN="$run_token"
|
|
1700
1712
|
exec "$timeout_bin" "$boot_timeout" sh -c "$method"
|
|
1701
1713
|
) >"$boot_log" 2>&1 &
|
|
1702
1714
|
app_pid=$!
|
|
@@ -1769,7 +1781,7 @@ verify_gate_runtime() {
|
|
|
1769
1781
|
# Reclaim BOTH the detected port and the actually-bound port (scraped from the
|
|
1770
1782
|
# boot log) so a server that daemonized onto a different port than we guessed
|
|
1771
1783
|
# does not leak. scraped_port may be empty (no banner) -- teardown handles it.
|
|
1772
|
-
_verify_runtime_teardown "$app_pid" "$port" "$scraped_port"
|
|
1784
|
+
_verify_runtime_teardown "$app_pid" "$port" "$scraped_port" "$run_token"
|
|
1773
1785
|
|
|
1774
1786
|
# Interpret the result.
|
|
1775
1787
|
if [ "$answered" != "true" ]; then
|
|
@@ -1907,8 +1919,34 @@ SMOKE_SCRIPT
|
|
|
1907
1919
|
|
|
1908
1920
|
# Teardown: terminate the boot launcher and any process still holding the port.
|
|
1909
1921
|
# Bounded and best effort; never blocks the gate.
|
|
1922
|
+
# Does pid's environment carry LOKI_VERIFY_RUN_TOKEN=<tok> exactly? Portable
|
|
1923
|
+
# ownership proof that survives setsid() (see the fourth arm below for why
|
|
1924
|
+
# pgid/ppid checks cannot). Fails CLOSED: an empty token, an unreadable
|
|
1925
|
+
# environment, or any ambiguity returns "no match" -- never a false positive
|
|
1926
|
+
# that would authorize a kill. Checked directly (not assumed) on this
|
|
1927
|
+
# codebase's target platforms: Linux via /proc/<pid>/environ (NUL-separated,
|
|
1928
|
+
# same-uid readable); macOS/BSD via `ps -E -ww -o command=` (the `-E` flag
|
|
1929
|
+
# specifically requests the environment block; `-e` on BSD ps means "all
|
|
1930
|
+
# processes" and shows nothing extra -- do not confuse the two).
|
|
1931
|
+
_verify_runtime_pid_has_token() {
|
|
1932
|
+
local pid="$1" tok="$2"
|
|
1933
|
+
case "$pid" in ''|*[!0-9]*) return 1 ;; esac
|
|
1934
|
+
[ -n "$tok" ] || return 1
|
|
1935
|
+
if [ -r "/proc/$pid/environ" ]; then
|
|
1936
|
+
tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | grep -qxF "LOKI_VERIFY_RUN_TOKEN=$tok"
|
|
1937
|
+
return $?
|
|
1938
|
+
fi
|
|
1939
|
+
if command -v ps >/dev/null 2>&1; then
|
|
1940
|
+
case " $(ps -E -ww -o command= -p "$pid" 2>/dev/null) " in
|
|
1941
|
+
*" LOKI_VERIFY_RUN_TOKEN=$tok "*) return 0 ;;
|
|
1942
|
+
*) return 1 ;;
|
|
1943
|
+
esac
|
|
1944
|
+
fi
|
|
1945
|
+
return 1
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1910
1948
|
_verify_runtime_teardown() {
|
|
1911
|
-
local app_pid="$1" port="$2" scraped_port="${3:-}"
|
|
1949
|
+
local app_pid="$1" port="$2" scraped_port="${3:-}" run_token="${4:-}"
|
|
1912
1950
|
if [ -n "$app_pid" ]; then
|
|
1913
1951
|
# Best: kill the launcher's whole process GROUP so a server it spawned in
|
|
1914
1952
|
# a subshell (e.g. `vite &`) dies too. GUARDED against self-suicide: in a
|
|
@@ -1943,6 +1981,22 @@ _verify_runtime_teardown() {
|
|
|
1943
1981
|
# Reclaim BOTH the detected port and the actually-bound (scraped) port from
|
|
1944
1982
|
# any orphan that outlived the launcher -- a daemonized server that bound a
|
|
1945
1983
|
# different port than we guessed would otherwise leak. Bounded, best effort.
|
|
1984
|
+
#
|
|
1985
|
+
# SCOPING (D14/D15/D16 class): `lsof -ti tcp:$port` alone, with no
|
|
1986
|
+
# -sTCP:LISTEN filter, matches EVERY socket referencing that port number,
|
|
1987
|
+
# including a CLIENT with an established connection whose remote port
|
|
1988
|
+
# happens to be $port (e.g. a browser tab open to localhost:$port) -- that
|
|
1989
|
+
# process gets SIGKILL'd even though it never bound anything. And with no
|
|
1990
|
+
# ownership check at all, ANY process (this run's or not) listening on the
|
|
1991
|
+
# reused port gets killed. Fixed to (1) filter to LISTEN sockets only, and
|
|
1992
|
+
# (2) verify each candidate is actually part of THIS launcher's tree before
|
|
1993
|
+
# killing it: its pgid matches $child_pgid (the group this teardown just
|
|
1994
|
+
# tried to signal above), or its direct parent is $app_pid. A holder that
|
|
1995
|
+
# matches neither is left alone -- this run never bound that port, so it
|
|
1996
|
+
# never reclaims it. Trade-off: a server that called setsid() into a fresh
|
|
1997
|
+
# session AND was reparented away from app_pid (neither condition holds)
|
|
1998
|
+
# will leak here; failing closed on an unverified kill is the safer
|
|
1999
|
+
# direction (see docs/v10/DECISIONS.md D14/D15/D16).
|
|
1946
2000
|
if command -v lsof >/dev/null 2>&1; then
|
|
1947
2001
|
# Build a unique, non-empty port list (scraped only if it differs).
|
|
1948
2002
|
local _ports="$port"
|
|
@@ -1951,10 +2005,62 @@ _verify_runtime_teardown() {
|
|
|
1951
2005
|
for _rp in $_ports; do
|
|
1952
2006
|
[ -z "$_rp" ] && continue
|
|
1953
2007
|
local holders
|
|
1954
|
-
holders="$(lsof -ti tcp:"$_rp" 2>/dev/null || true)"
|
|
2008
|
+
holders="$(lsof -ti tcp:"$_rp" -sTCP:LISTEN 2>/dev/null || true)"
|
|
1955
2009
|
if [ -n "$holders" ]; then
|
|
1956
2010
|
printf '%s\n' "$holders" | while IFS= read -r pid; do
|
|
1957
|
-
[ -
|
|
2011
|
+
[ -z "$pid" ] && continue
|
|
2012
|
+
local _h_pgid="" _h_ppid=""
|
|
2013
|
+
_h_pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
|
|
2014
|
+
_h_ppid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
|
|
2015
|
+
# THIRD ARM (council round 2, REJECT reproduced and fixed):
|
|
2016
|
+
# GNU `timeout` (this codebase's launcher, verify.sh:1699)
|
|
2017
|
+
# makes ITSELF the process-group leader, so the group's
|
|
2018
|
+
# pgid equals timeout's own pid, which equals app_pid (the
|
|
2019
|
+
# subshell execs straight into timeout with no fork). If
|
|
2020
|
+
# the launched command daemonizes -- forks a server and
|
|
2021
|
+
# the parent (timeout, then sh -c) exits -- by teardown
|
|
2022
|
+
# time: child_pgid is EMPTY (timeout already exited, so
|
|
2023
|
+
# `ps -o pgid= -p "$app_pid"` finds nothing); the orphan's
|
|
2024
|
+
# ppid is 1, not app_pid; but its pgid is STILL app_pid,
|
|
2025
|
+
# the value it inherited when timeout created the group.
|
|
2026
|
+
# Neither of the two arms above can ever match this case,
|
|
2027
|
+
# so the classic "daemonized" leak this function's own
|
|
2028
|
+
# comments describe was never reclaimed. Reproduced
|
|
2029
|
+
# directly: own daemon listener LEAKED pre-this-arm,
|
|
2030
|
+
# reclaimed after. Safe as an ownership proof, not a
|
|
2031
|
+
# loosening: a process group's pgid cannot be reassigned
|
|
2032
|
+
# to an unrelated group while any member of the original
|
|
2033
|
+
# group (here, the orphan itself, still alive and
|
|
2034
|
+
# reporting this exact pgid) still exists -- POSIX pgid
|
|
2035
|
+
# reuse is blocked until the whole group is empty.
|
|
2036
|
+
# FOURTH ARM (council round 4, 2 of 4 reviewers REJECTed
|
|
2037
|
+
# with an identical reproduction; verified independently
|
|
2038
|
+
# before applying): a process launched via
|
|
2039
|
+
# child_process.spawn({detached:true}) (Node's setsid()
|
|
2040
|
+
# wrapper) becomes its OWN session and process group
|
|
2041
|
+
# leader -- its pgid equals its OWN pid, not app_pid or
|
|
2042
|
+
# child_pgid, and its ppid is 1. None of the three arms
|
|
2043
|
+
# above can ever match that shape, which is exactly
|
|
2044
|
+
# test-runtime-gate.sh case I's fixture (the v7.109.0
|
|
2045
|
+
# daemonize-leak regression test) and a real pattern many
|
|
2046
|
+
# servers use to daemonize. Environment variables survive
|
|
2047
|
+
# setsid()/fork()/exec() regardless of process-group
|
|
2048
|
+
# changes, so LOKI_VERIFY_RUN_TOKEN (exported into the
|
|
2049
|
+
# launched app's env at boot, see the launch site above)
|
|
2050
|
+
# is checked here as a portability-verified ownership
|
|
2051
|
+
# proof (see _verify_runtime_pid_has_token; confirmed
|
|
2052
|
+
# directly on macOS with `ps -E -ww`, not assumed).
|
|
2053
|
+
# Residual, disclosed gap: a daemon that scrubs its own
|
|
2054
|
+
# environment (env -i, some process supervisors) before
|
|
2055
|
+
# detaching still leaks -- no ownership proof survives
|
|
2056
|
+
# that, and failing closed on an unverified kill remains
|
|
2057
|
+
# the safer direction.
|
|
2058
|
+
if { [ -n "$child_pgid" ] && [ -n "$_h_pgid" ] && [ "$_h_pgid" = "$child_pgid" ]; } \
|
|
2059
|
+
|| { [ -n "$app_pid" ] && [ -n "$_h_ppid" ] && [ "$_h_ppid" = "$app_pid" ]; } \
|
|
2060
|
+
|| { [ -n "$app_pid" ] && [ -n "$_h_pgid" ] && [ "$_h_pgid" = "$app_pid" ]; } \
|
|
2061
|
+
|| _verify_runtime_pid_has_token "$pid" "$run_token"; then
|
|
2062
|
+
kill -9 "$pid" 2>/dev/null || true
|
|
2063
|
+
fi
|
|
1958
2064
|
done
|
|
1959
2065
|
fi
|
|
1960
2066
|
done
|
package/bin/loki
CHANGED
|
@@ -348,6 +348,18 @@ if [ "${1:-}" = "trust" ]; then
|
|
|
348
348
|
done
|
|
349
349
|
fi
|
|
350
350
|
|
|
351
|
+
# `doctor --airgap` (egress audit) lives only in bash (_loki_airgap_audit).
|
|
352
|
+
# The Bun doctor rejected the flag, so the default route had no audit at all.
|
|
353
|
+
# Routed here, flag-anywhere, so the committed dist needs no rebuild and bash
|
|
354
|
+
# stays the single implementation; bash main() fires the one cli_command event.
|
|
355
|
+
if [ "${1:-}" = "doctor" ]; then
|
|
356
|
+
for _doctor_arg in "${@:2}"; do
|
|
357
|
+
if [ "$_doctor_arg" = "--airgap" ]; then
|
|
358
|
+
exec "$BASH_CLI" "$@"
|
|
359
|
+
fi
|
|
360
|
+
done
|
|
361
|
+
fi
|
|
362
|
+
|
|
351
363
|
# CLI consolidation (Phase B): `report kpis` is the canonical form of the
|
|
352
364
|
# Bun-only KPI snapshot. The `report` noun is otherwise bash-owned (every other
|
|
353
365
|
# subcommand -- session/metrics/cost/export/share/dogfood -- routes to bash
|
package/dashboard/__init__.py
CHANGED
package/dashboard/api_v2.py
CHANGED
|
@@ -894,6 +894,10 @@ async def verify_audit_integrity():
|
|
|
894
894
|
|
|
895
895
|
return {
|
|
896
896
|
"valid": aggregate["valid"],
|
|
897
|
+
# verified/status: whether anything was checked (valid stays True for
|
|
898
|
+
# an empty chain); see audit.verify_all_logs_in_dir.
|
|
899
|
+
"verified": aggregate["verified"],
|
|
900
|
+
"status": aggregate["status"],
|
|
897
901
|
"files_checked": aggregate["files_checked"],
|
|
898
902
|
"files_skipped": aggregate.get("files_skipped", 0),
|
|
899
903
|
"entries_checked": aggregate.get("entries_checked", 0),
|
package/dashboard/audit.py
CHANGED
|
@@ -657,7 +657,8 @@ def verify_all_logs_in_dir(audit_dir) -> dict:
|
|
|
657
657
|
if not audit_dir.exists():
|
|
658
658
|
return {"valid": True, "files_checked": 0, "files_skipped": 0,
|
|
659
659
|
"entries_checked": 0, "first_tampered_file": None,
|
|
660
|
-
"first_tampered_line": None, "genesis_file": None
|
|
660
|
+
"first_tampered_line": None, "genesis_file": None,
|
|
661
|
+
"verified": False, "status": "nothing_checked"}
|
|
661
662
|
# v7.7.15 council fix (Opus 2): rotated files have name shape
|
|
662
663
|
# `audit-YYYY-MM-DD.HHMMSS.jsonl` (from `_rotate_logs_if_needed` at
|
|
663
664
|
# line 167). Lexicographic sort puts `audit-2026-05-04.123456.jsonl`
|
|
@@ -689,8 +690,13 @@ def verify_all_logs_in_dir(audit_dir) -> dict:
|
|
|
689
690
|
"first_tampered_file": str(log_file),
|
|
690
691
|
"first_tampered_line": result.get("first_tampered_line"),
|
|
691
692
|
"genesis_file": genesis_file,
|
|
693
|
+
"verified": False,
|
|
694
|
+
"status": "tampered",
|
|
692
695
|
}
|
|
693
696
|
prev_hash = result.get("last_hash", prev_hash)
|
|
697
|
+
# `valid` means "no break found" and stays True for an empty chain (the
|
|
698
|
+
# cross-chain verifier reads it). `verified` and `status` say whether
|
|
699
|
+
# anything was actually checked: zero files checked is not a verified chain.
|
|
694
700
|
return {
|
|
695
701
|
"valid": True,
|
|
696
702
|
"files_checked": files_checked,
|
|
@@ -699,6 +705,8 @@ def verify_all_logs_in_dir(audit_dir) -> dict:
|
|
|
699
705
|
"first_tampered_file": None,
|
|
700
706
|
"first_tampered_line": None,
|
|
701
707
|
"genesis_file": genesis_file,
|
|
708
|
+
"verified": files_checked > 0,
|
|
709
|
+
"status": "verified" if files_checked > 0 else "nothing_checked",
|
|
702
710
|
}
|
|
703
711
|
|
|
704
712
|
|
package/dashboard/auth.py
CHANGED
|
@@ -343,6 +343,18 @@ def list_tokens(include_revoked: bool = False) -> list[dict]:
|
|
|
343
343
|
return result
|
|
344
344
|
|
|
345
345
|
|
|
346
|
+
def _deadline_passed(value) -> bool:
|
|
347
|
+
"""True when an ISO-8601 deadline is in the past, or cannot be read.
|
|
348
|
+
|
|
349
|
+
Fails closed: an unparseable value, or a naive timestamp that cannot be
|
|
350
|
+
ordered against an aware "now", is treated as passed.
|
|
351
|
+
"""
|
|
352
|
+
try:
|
|
353
|
+
return datetime.now(timezone.utc) > datetime.fromisoformat(str(value))
|
|
354
|
+
except (TypeError, ValueError):
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
|
|
346
358
|
def validate_token(raw_token: str) -> Optional[dict]:
|
|
347
359
|
"""
|
|
348
360
|
Validate a raw token.
|
|
@@ -372,10 +384,10 @@ def validate_token(raw_token: str) -> Optional[dict]:
|
|
|
372
384
|
if matched_token.get("revoked"):
|
|
373
385
|
return None
|
|
374
386
|
|
|
375
|
-
# Check expiration
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if
|
|
387
|
+
# Check expiration, and the grace period of a rotated-out key
|
|
388
|
+
# (api_keys.rotate_key sets rotation_expires_at on the old key).
|
|
389
|
+
for field in ("expires_at", "rotation_expires_at"):
|
|
390
|
+
if matched_token.get(field) and _deadline_passed(matched_token[field]):
|
|
379
391
|
return None
|
|
380
392
|
|
|
381
393
|
# Update last used
|
package/dashboard/registry.py
CHANGED
|
@@ -16,6 +16,8 @@ from pathlib import Path
|
|
|
16
16
|
from typing import Iterator, Optional
|
|
17
17
|
import hashlib
|
|
18
18
|
|
|
19
|
+
from .api_runs import _efficiency_module
|
|
20
|
+
|
|
19
21
|
|
|
20
22
|
# Registry file location
|
|
21
23
|
REGISTRY_DIR = Path.home() / ".loki" / "dashboard"
|
|
@@ -686,13 +688,14 @@ def _read_project_run_snapshot(path: str) -> dict:
|
|
|
686
688
|
.loki/context/tracking.json fallback (totals.total_cost_usd)
|
|
687
689
|
|
|
688
690
|
Returns a dict with phase, iteration, cost_usd, started_at, and ended_at
|
|
689
|
-
(best-effort; missing values default to None
|
|
690
|
-
|
|
691
|
+
(best-effort; missing values default to None). iteration and cost_usd are
|
|
692
|
+
None, never 0 / 0.0, when nothing recorded them. Never raises: any file problem degrades the
|
|
693
|
+
affected field to its default.
|
|
691
694
|
"""
|
|
692
695
|
snap = {
|
|
693
696
|
"phase": "",
|
|
694
|
-
"iteration":
|
|
695
|
-
"cost_usd":
|
|
697
|
+
"iteration": None,
|
|
698
|
+
"cost_usd": None,
|
|
696
699
|
"started_at": None,
|
|
697
700
|
"ended_at": None,
|
|
698
701
|
}
|
|
@@ -708,8 +711,8 @@ def _read_project_run_snapshot(path: str) -> dict:
|
|
|
708
711
|
if isinstance(state, dict):
|
|
709
712
|
_p = state.get("phase", "")
|
|
710
713
|
snap["phase"] = _p if isinstance(_p, str) else ""
|
|
711
|
-
_i = state.get("iteration"
|
|
712
|
-
snap["iteration"] = _i if isinstance(_i, int) else
|
|
714
|
+
_i = state.get("iteration")
|
|
715
|
+
snap["iteration"] = _i if isinstance(_i, int) and not isinstance(_i, bool) else None
|
|
713
716
|
except (json.JSONDecodeError, OSError, ValueError):
|
|
714
717
|
pass
|
|
715
718
|
|
|
@@ -727,6 +730,15 @@ def _read_project_run_snapshot(path: str) -> dict:
|
|
|
727
730
|
pass
|
|
728
731
|
|
|
729
732
|
# Cost: sum per-iteration efficiency files; fall back to context tracking.
|
|
733
|
+
# A present record is not a measurement: only a record that carried an
|
|
734
|
+
# observed value (the canonical record_is_measured, the same predicate as
|
|
735
|
+
# dashboard/server.py _record_is_measured) and a numeric cost counts, so an
|
|
736
|
+
# all-zero or field-less record, or the 0.0 a fresh tracking.json is seeded
|
|
737
|
+
# with, reads as unknown instead of $0.00. A measured $0.00 stays 0.0.
|
|
738
|
+
# ponytail: a measured record with tokens but no cost_usd adds nothing (the
|
|
739
|
+
# fleet does not price tokens); price it here if unpriced records appear.
|
|
740
|
+
eff_mod = _efficiency_module()
|
|
741
|
+
measured = eff_mod.record_is_measured if eff_mod is not None else (lambda _rec: False)
|
|
730
742
|
cost = 0.0
|
|
731
743
|
found_cost = False
|
|
732
744
|
eff_dir = loki_dir / "metrics" / "efficiency"
|
|
@@ -738,7 +750,7 @@ def _read_project_run_snapshot(path: str) -> dict:
|
|
|
738
750
|
if not isinstance(data, dict):
|
|
739
751
|
continue
|
|
740
752
|
c = data.get("cost_usd")
|
|
741
|
-
if isinstance(c, (int, float)):
|
|
753
|
+
if isinstance(c, (int, float)) and not isinstance(c, bool) and measured(data):
|
|
742
754
|
cost += float(c)
|
|
743
755
|
found_cost = True
|
|
744
756
|
except (json.JSONDecodeError, OSError, ValueError):
|
|
@@ -754,11 +766,15 @@ def _read_project_run_snapshot(path: str) -> dict:
|
|
|
754
766
|
totals = ctx.get("totals", {})
|
|
755
767
|
if isinstance(totals, dict):
|
|
756
768
|
tc = totals.get("total_cost_usd")
|
|
757
|
-
if isinstance(tc, (int, float))
|
|
769
|
+
if isinstance(tc, (int, float)) and not isinstance(tc, bool) and measured({
|
|
770
|
+
"cost_usd": tc,
|
|
771
|
+
"input_tokens": totals.get("total_input"),
|
|
772
|
+
"output_tokens": totals.get("total_output")}):
|
|
758
773
|
cost = float(tc)
|
|
774
|
+
found_cost = True
|
|
759
775
|
except (json.JSONDecodeError, OSError, ValueError):
|
|
760
776
|
pass
|
|
761
|
-
snap["cost_usd"] = round(cost, 6)
|
|
777
|
+
snap["cost_usd"] = round(cost, 6) if found_cost else None
|
|
762
778
|
return snap
|
|
763
779
|
|
|
764
780
|
|
|
@@ -836,7 +852,7 @@ def get_fleet_runs(include_inactive: bool = True) -> list[dict]:
|
|
|
836
852
|
"running": running,
|
|
837
853
|
"phase": snap.get("phase", ""),
|
|
838
854
|
"iteration": snap.get("iteration", 0),
|
|
839
|
-
"cost_usd": snap.get("cost_usd"
|
|
855
|
+
"cost_usd": snap.get("cost_usd"),
|
|
840
856
|
"started_at": started_at,
|
|
841
857
|
"duration_seconds": duration_seconds,
|
|
842
858
|
"port": p.get("port"),
|
|
@@ -862,16 +878,22 @@ def get_fleet_summary(include_inactive: bool = True) -> dict:
|
|
|
862
878
|
Returns counts (total / running / stopped) and a summed cost across all
|
|
863
879
|
registered projects. v1 polls the shared metadata store (see
|
|
864
880
|
get_fleet_runs); not a controller. Never raises.
|
|
881
|
+
|
|
882
|
+
total_cost_usd sums only runs that recorded a cost and is None when none
|
|
883
|
+
did; total_cost_partial is True when some runs recorded no cost, so the
|
|
884
|
+
total is a lower bound, not the whole bill.
|
|
865
885
|
"""
|
|
866
886
|
runs = get_fleet_runs(include_inactive=include_inactive)
|
|
867
887
|
total = len(runs)
|
|
868
888
|
running = sum(1 for r in runs if r.get("running"))
|
|
869
|
-
|
|
889
|
+
costs = [float(r["cost_usd"]) for r in runs
|
|
890
|
+
if isinstance(r.get("cost_usd"), (int, float)) and not isinstance(r.get("cost_usd"), bool)]
|
|
870
891
|
return {
|
|
871
892
|
"total_runs": total,
|
|
872
893
|
"running_runs": running,
|
|
873
894
|
"stopped_runs": total - running,
|
|
874
|
-
"total_cost_usd":
|
|
895
|
+
"total_cost_usd": round(sum(costs), 6) if costs else None,
|
|
896
|
+
"total_cost_partial": 0 < len(costs) < total,
|
|
875
897
|
}
|
|
876
898
|
|
|
877
899
|
|
|
@@ -245,32 +245,36 @@ class RigourIntegration:
|
|
|
245
245
|
for sev in findings:
|
|
246
246
|
findings[sev] = int(_extract_num(raw_findings, sev, 0))
|
|
247
247
|
|
|
248
|
-
# Extract category scores
|
|
248
|
+
# Extract category scores. A category Rigour did not report is None
|
|
249
|
+
# (the panel shows "--"), never a scored 0.
|
|
249
250
|
raw_categories = raw.get("categories", raw.get("scores", {}))
|
|
251
|
+
if not isinstance(raw_categories, dict):
|
|
252
|
+
raw_categories = {}
|
|
250
253
|
categories = {
|
|
251
|
-
"security": _extract_num(raw_categories, "security",
|
|
252
|
-
"code_quality": _extract_num(raw_categories, "code_quality", _extract_num(raw_categories, "codeQuality",
|
|
253
|
-
"compliance": _extract_num(raw_categories, "compliance",
|
|
254
|
-
"best_practices": _extract_num(raw_categories, "best_practices", _extract_num(raw_categories, "bestPractices",
|
|
254
|
+
"security": _extract_num(raw_categories, "security", None),
|
|
255
|
+
"code_quality": _extract_num(raw_categories, "code_quality", _extract_num(raw_categories, "codeQuality", None)),
|
|
256
|
+
"compliance": _extract_num(raw_categories, "compliance", None),
|
|
257
|
+
"best_practices": _extract_num(raw_categories, "best_practices", _extract_num(raw_categories, "bestPractices", None)),
|
|
255
258
|
}
|
|
256
259
|
|
|
257
|
-
# Compute overall score
|
|
260
|
+
# Compute overall score: the reported one, else the average of the
|
|
261
|
+
# categories that were reported, else None (no score, no grade).
|
|
258
262
|
raw_score = raw.get("score", raw.get("overall_score"))
|
|
263
|
+
score = None
|
|
259
264
|
if raw_score is not None:
|
|
260
265
|
try:
|
|
261
266
|
score = float(raw_score)
|
|
262
267
|
except (ValueError, TypeError):
|
|
263
|
-
score =
|
|
268
|
+
score = None
|
|
264
269
|
else:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
score = round(sum(non_zero) / len(non_zero), 1) if non_zero else 0.0
|
|
270
|
+
reported = [v for v in categories.values() if v is not None]
|
|
271
|
+
score = round(sum(reported) / len(reported), 1) if reported else None
|
|
268
272
|
|
|
269
273
|
result: dict[str, Any] = {
|
|
270
274
|
"available": True,
|
|
271
|
-
"score": round(score, 1),
|
|
275
|
+
"score": round(score, 1) if score is not None else None,
|
|
272
276
|
"max_score": 100,
|
|
273
|
-
"grade": _score_to_grade(score),
|
|
277
|
+
"grade": _score_to_grade(score) if score is not None else None,
|
|
274
278
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
275
279
|
"preset": preset,
|
|
276
280
|
"findings": findings,
|
|
@@ -301,8 +305,9 @@ class RigourIntegration:
|
|
|
301
305
|
"grade": None,
|
|
302
306
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
303
307
|
"preset": preset,
|
|
304
|
-
|
|
305
|
-
"
|
|
308
|
+
# Nothing was scanned: no counts and no category scores.
|
|
309
|
+
"findings": {"critical": None, "major": None, "minor": None, "info": None},
|
|
310
|
+
"categories": {"security": None, "code_quality": None, "compliance": None, "best_practices": None},
|
|
306
311
|
"reason": reason,
|
|
307
312
|
}
|
|
308
313
|
|
|
@@ -325,9 +330,12 @@ class RigourIntegration:
|
|
|
325
330
|
}
|
|
326
331
|
|
|
327
332
|
|
|
328
|
-
def _extract_num(d: dict, key: str, default: float = 0) -> float:
|
|
329
|
-
"""Safely extract a numeric value from a dict."""
|
|
333
|
+
def _extract_num(d: dict, key: str, default: Optional[float] = 0) -> Optional[float]:
|
|
334
|
+
"""Safely extract a numeric value from a dict; `default` when absent or not numeric."""
|
|
335
|
+
v = d.get(key)
|
|
336
|
+
if v is None or isinstance(v, bool):
|
|
337
|
+
return default
|
|
330
338
|
try:
|
|
331
|
-
return float(
|
|
339
|
+
return float(v)
|
|
332
340
|
except (ValueError, TypeError):
|
|
333
341
|
return default
|