loki-mode 9.54.2 → 9.56.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.
Files changed (109) hide show
  1. package/.github/actions/issue-to-pr/action.yml +165 -29
  2. package/.github/workflows/loki-issue-to-pr.yml +137 -24
  3. package/README.md +1 -1
  4. package/SKILL.md +2 -2
  5. package/VERSION +1 -1
  6. package/autonomy/completion-council.sh +110 -34
  7. package/autonomy/crash.sh +6 -6
  8. package/autonomy/lib/done-recognition.sh +7 -0
  9. package/autonomy/lib/prd-enrich.sh +7 -0
  10. package/autonomy/lib/proof-check.sh +9 -0
  11. package/autonomy/lib/proof-generator.py +12 -3
  12. package/autonomy/lib/proof-verify.py +93 -21
  13. package/autonomy/lib/voter-agents.sh +7 -0
  14. package/autonomy/lib/workspace_diff.py +18 -7
  15. package/autonomy/loki +537 -122
  16. package/autonomy/quickstart.sh +7 -0
  17. package/autonomy/run.sh +956 -37
  18. package/autonomy/verify.sh +114 -8
  19. package/bin/loki +12 -0
  20. package/dashboard/__init__.py +1 -1
  21. package/dashboard/api_keys.py +12 -5
  22. package/dashboard/build_supervisor.py +25 -1
  23. package/dashboard/server.py +46 -24
  24. package/dashboard/static/index.html +57 -57
  25. package/docs/INSTALLATION.md +6 -5
  26. package/docs/VERIFIED-COMPLETION-PLAN.md +7 -5
  27. package/docs/air-gapped.md +39 -17
  28. package/docs/enterprise/security.md +10 -13
  29. package/docs/environment-variables.md +2 -0
  30. package/docs/exit-codes.md +76 -2
  31. package/docs/v10/BACKLOG.md +56 -0
  32. package/docs/v10/BOARD.md +118 -32
  33. package/docs/v10/CONTROL.md +36 -0
  34. package/docs/v10/DECISIONS.md +74 -0
  35. package/docs/v10/METRICS.md +78 -1
  36. package/docs/v10/PROGRESS.md +928 -0
  37. package/loki-ts/dist/loki.js +331 -327
  38. package/mcp/__init__.py +1 -1
  39. package/package.json +1 -1
  40. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  41. package/providers/claude.sh +1 -1
  42. package/providers/loader.sh +10 -0
  43. package/providers/model_catalog.json +12 -5
  44. package/providers/models.sh +1 -1
  45. package/references/core-workflow.md +1 -1
  46. package/tools/receipt-attest.py +1 -1
  47. package/web-app/dist/assets/{AdminPage-CZrBkzRF.js → AdminPage-Oyx-w2Ch.js} +1 -1
  48. package/web-app/dist/assets/{Avatar-gQIp6FBI.js → Avatar-D60m65JB.js} +1 -1
  49. package/web-app/dist/assets/{Badge-D2_fBTFY.js → Badge-CqfZNaem.js} +1 -1
  50. package/web-app/dist/assets/{Button-D5qDd2ef.js → Button-BqjzE4fz.js} +1 -1
  51. package/web-app/dist/assets/CockpitPage-Czk7o_ou.js +47 -0
  52. package/web-app/dist/assets/ComparePage-BEfTrTb1.js +1 -0
  53. package/web-app/dist/assets/{ErrorBoundary-BRABI-BT.js → ErrorBoundary-BrLk5nkO.js} +1 -1
  54. package/web-app/dist/assets/EvidenceReceiptPanel-9vEpi5ir.js +1 -0
  55. package/web-app/dist/assets/{GitHubIssuesPanel-BC5GzKUN.js → GitHubIssuesPanel-D6izZx0w.js} +1 -1
  56. package/web-app/dist/assets/GitHubPRsPanel-BTkG2FgA.js +17 -0
  57. package/web-app/dist/assets/{HomePage-D_CDMoen.js → HomePage-nsr4kpN1.js} +2 -2
  58. package/web-app/dist/assets/{LoginPage-BBNA_N9t.js → LoginPage-BVblQrr9.js} +1 -1
  59. package/web-app/dist/assets/{MagicPage-CnFRZCMx.js → MagicPage-CmnSyLq7.js} +1 -1
  60. package/web-app/dist/assets/{MetricsPage-CPfLMRlh.js → MetricsPage-BVfwU81j.js} +1 -1
  61. package/web-app/dist/assets/{NotFoundPage-lA0XSGTz.js → NotFoundPage-AYQ6uqqI.js} +1 -1
  62. package/web-app/dist/assets/{ProjectPage-CNAPbr9z.js → ProjectPage-DMnbw9X9.js} +3 -3
  63. package/web-app/dist/assets/{ProjectsPage-CMtm8goN.js → ProjectsPage-CNVTAF2N.js} +1 -1
  64. package/web-app/dist/assets/{SettingsPage-BhIjVMYE.js → SettingsPage-D1_qasFD.js} +3 -3
  65. package/web-app/dist/assets/{ShowcasePage-BndtGeYo.js → ShowcasePage-2dBCjLXn.js} +1 -1
  66. package/web-app/dist/assets/{SystemSettingsPage-euXI-KXE.js → SystemSettingsPage-Djo6IE4u.js} +1 -1
  67. package/web-app/dist/assets/{TeamsPage-ClUKecun.js → TeamsPage-CY8p-yxD.js} +1 -1
  68. package/web-app/dist/assets/{TemplatesPage-BWHWVk0L.js → TemplatesPage-B6iFgH6t.js} +1 -1
  69. package/web-app/dist/assets/{TerminalOutput-Cv3N3Szf.js → TerminalOutput-B3yzZe-h.js} +1 -1
  70. package/web-app/dist/assets/{activity-dEhz-bXR.js → activity-CNfAjtRv.js} +1 -1
  71. package/web-app/dist/assets/{bell-B3XUMq1t.js → bell-CMETsHPH.js} +1 -1
  72. package/web-app/dist/assets/{bot-X-ssNlwR.js → bot-C9kXvk1i.js} +1 -1
  73. package/web-app/dist/assets/{check-C_9Nx7kb.js → check-BRK-zLB5.js} +1 -1
  74. package/web-app/dist/assets/{chevron-left-tvNswo3A.js → chevron-left-CxI5uD4g.js} +1 -1
  75. package/web-app/dist/assets/{circle-alert-CSB88nEs.js → circle-alert-CVFRPWpv.js} +1 -1
  76. package/web-app/dist/assets/{clock-C8p2e4NI.js → clock-CLYV8a_x.js} +1 -1
  77. package/web-app/dist/assets/{cloud-CKp2ZvhG.js → cloud-CiISiIOa.js} +1 -1
  78. package/web-app/dist/assets/{code-xml-dY70LCHD.js → code-xml-DHsgqwV-.js} +1 -1
  79. package/web-app/dist/assets/{database-DObn5fmn.js → database-DbvUqtz3.js} +1 -1
  80. package/web-app/dist/assets/{dollar-sign-DSu6n3K3.js → dollar-sign-Ct0lRPHw.js} +1 -1
  81. package/web-app/dist/assets/{file-code-corner-DpCnS73z.js → file-code-corner-D1sjFEh7.js} +1 -1
  82. package/web-app/dist/assets/{file-plus-D3IPKCf1.js → file-plus-DnmX18Fi.js} +1 -1
  83. package/web-app/dist/assets/{globe-C9QXkgH_.js → globe-bSpDNgoA.js} +1 -1
  84. package/web-app/dist/assets/{hammer-D_FtWe1y.js → hammer-Bj41Kvlq.js} +1 -1
  85. package/web-app/dist/assets/{index-CI54rara.css → index-BfQ0f3-S.css} +1 -1
  86. package/web-app/dist/assets/{index-Cz7q1bvC.js → index-CxajNJVu.js} +3 -3
  87. package/web-app/dist/assets/{layers-DsW7jnJX.js → layers-BNtBwYER.js} +1 -1
  88. package/web-app/dist/assets/{loader-circle-B3HspjKR.js → loader-circle-YY_VZofG.js} +1 -1
  89. package/web-app/dist/assets/{lock-DSI29pct.js → lock-rpjEPVoC.js} +1 -1
  90. package/web-app/dist/assets/{package-CLNxtA2x.js → package-desXU6XA.js} +1 -1
  91. package/web-app/dist/assets/{plus-DyI4WC4_.js → plus-7AoOU-I9.js} +1 -1
  92. package/web-app/dist/assets/{refresh-cw-D2dv0xA0.js → refresh-cw-oNj-Xn4B.js} +1 -1
  93. package/web-app/dist/assets/{rotate-ccw-CBvihA-a.js → rotate-ccw-DeiFjfVO.js} +1 -1
  94. package/web-app/dist/assets/{scroll-text-DvoOvAl_.js → scroll-text-Q9AzWiNV.js} +1 -1
  95. package/web-app/dist/assets/{server-DenWvSUt.js → server-DKgVwfek.js} +1 -1
  96. package/web-app/dist/assets/{shield-alert-40cX_FvG.js → shield-alert-G6x-bS-I.js} +1 -1
  97. package/web-app/dist/assets/{thumbs-up-BrjZ6QuV.js → thumbs-up-Qhk0yvw0.js} +1 -1
  98. package/web-app/dist/assets/{trash-2-C6evwkEH.js → trash-2-D23gvznl.js} +1 -1
  99. package/web-app/dist/assets/{trending-down-CfbZis3O.js → trending-down-CSQClPrr.js} +1 -1
  100. package/web-app/dist/assets/{trending-up-DWuEK3WC.js → trending-up-sjMSd1wc.js} +1 -1
  101. package/web-app/dist/assets/{upload-B9DFBcPG.js → upload-Dchovhld.js} +1 -1
  102. package/web-app/dist/assets/{usePolling-BryVC-T1.js → usePolling-DwLceXwF.js} +1 -1
  103. package/web-app/dist/assets/{user-DMRXwZiw.js → user-BqZM8CmD.js} +1 -1
  104. package/web-app/dist/index.html +2 -2
  105. package/web-app/server.py +52 -17
  106. package/web-app/dist/assets/CockpitPage-DVMk2RUK.js +0 -47
  107. package/web-app/dist/assets/ComparePage-NMLyE1xL.js +0 -1
  108. package/web-app/dist/assets/EvidenceReceiptPanel-D2LNKqUx.js +0 -1
  109. package/web-app/dist/assets/GitHubPRsPanel-BSlmoVTS.js +0 -17
@@ -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
- # PORT is exported to the resolved port so 12-factor apps (node, many python
1694
- # frameworks) listen where the probe looks; apps that hardcode a port ignore
1695
- # it harmlessly. This keeps boot-port and probe-port consistent.
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
- [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true
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
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.54.2"
10
+ __version__ = "9.56.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -254,6 +254,13 @@ def list_keys_with_details(include_rotating: bool = True) -> list[dict]:
254
254
  Returns:
255
255
  List of key detail dicts (no hashes or raw tokens).
256
256
  """
257
+ # Purge rotating keys past their grace period before listing. This is the
258
+ # only live read path over the full token store, so it doubles as the
259
+ # sweep for cleanup_expired_rotating_keys, which otherwise has no caller
260
+ # (BACKLOG 33). Lazy, not scheduled: runs on whatever cadence callers
261
+ # already poll this list at.
262
+ cleanup_expired_rotating_keys()
263
+
257
264
  tokens = auth._load_tokens()
258
265
  result = []
259
266
 
@@ -298,15 +305,15 @@ def cleanup_expired_rotating_keys() -> list[str]:
298
305
  List of deleted key IDs.
299
306
  """
300
307
  tokens = auth._load_tokens()
301
- now = datetime.now(timezone.utc)
302
308
  to_delete = []
303
309
 
304
310
  for tid, entry in tokens["tokens"].items():
305
311
  rotation_exp = entry.get("rotation_expires_at")
306
- if rotation_exp:
307
- exp_dt = datetime.fromisoformat(rotation_exp)
308
- if now > exp_dt:
309
- to_delete.append(tid)
312
+ # auth._deadline_passed fails closed: an unparseable or naive
313
+ # timestamp counts as expired, matching what validate_token already
314
+ # rejects at validation time (BACKLOG 119).
315
+ if rotation_exp and auth._deadline_passed(rotation_exp):
316
+ to_delete.append(tid)
310
317
 
311
318
  for tid in to_delete:
312
319
  del tokens["tokens"][tid]
@@ -885,7 +885,31 @@ def _host_seatbelt_profile(
885
885
  lines.append(
886
886
  f'(deny network-outbound (remote tcp "*:{port}"))'
887
887
  )
888
- lines.append(f'(deny network-bind (local tcp "*:{port}"))')
888
+ # network-bind uses "tcp4", not "tcp": confirmed on macOS 27.0 (26A428)
889
+ # that sandbox-exec's (local tcp "*:PORT") filter on network-bind never
890
+ # matches an actual bind (a bind to 127.0.0.1 or 0.0.0.0 on the denied
891
+ # port succeeds), while (local tcp4 "*:PORT") correctly denies it with
892
+ # a clean PermissionError. network-outbound's "tcp" filter above is
893
+ # unaffected (verified separately: an outbound connect to a denied
894
+ # port is still refused), so only this line changes.
895
+ #
896
+ # Known residual gap, same platform bug family, found while fixing
897
+ # the above (not introduced by it -- reproduced identically against
898
+ # the un-patched "tcp" rule too): on this same macOS 27.0 build, EVERY
899
+ # port-qualified network-bind filter for IPv6 ("tcp6 \"*:PORT\"" and
900
+ # "ip6 \"*:PORT\"") also fails to match, so an IPv6 bind to the
901
+ # reserved port (e.g. "::1"/"::") still succeeds even with a "tcp6"
902
+ # deny added. Only a port-unqualified "(deny network-bind (local
903
+ # ip6))" was confirmed to actually block IPv6 binds, which would deny
904
+ # ALL IPv6 binding (not just the reserved ports) -- a materially wider
905
+ # behavior change than this port-scoping fix, and nothing in this
906
+ # codebase binds IPv6 today (grepped: no AF_INET6/IPv6 usage). Left
907
+ # unfixed here to keep this change scoped to the failing test; the
908
+ # reserved ports remain enforced for IPv4 (the only family in active
909
+ # use), and a confined build could still self-expose a reserved port
910
+ # over IPv6 loopback/wildcard. Tracked as a follow-up, not silently
911
+ # closed.
912
+ lines.append(f'(deny network-bind (local tcp4 "*:{port}"))')
889
913
  return ("\n".join(lines) + "\n").encode("utf-8")
890
914
 
891
915
 
@@ -8614,16 +8614,27 @@ async def get_pricing():
8614
8614
  # Completion Council API (v5.25.0)
8615
8615
  # =============================================================================
8616
8616
 
8617
+ def council_state(loki_dir: _Path) -> dict:
8618
+ """Read council state.json, distinguishing "no state yet" from "unreadable".
8619
+
8620
+ A missing state.json means the council genuinely has not recorded a vote:
8621
+ total_votes is really 0. A state.json that exists but fails to parse (torn
8622
+ write, corruption) means we do not know how many votes were recorded --
8623
+ total_votes must be None, not a fabricated 0 that reads as a real count.
8624
+ """
8625
+ state_file = loki_dir / "council" / "state.json"
8626
+ if not state_file.exists():
8627
+ return {"enabled": False, "total_votes": 0, "verdicts": []}
8628
+ try:
8629
+ return json.loads(state_file.read_text())
8630
+ except Exception:
8631
+ return {"enabled": None, "total_votes": None, "verdicts": None, "error": "unreadable_state"}
8632
+
8633
+
8617
8634
  @app.get("/api/council/state", dependencies=[Depends(auth.require_scope("read"))])
8618
8635
  async def get_council_state():
8619
8636
  """Get current Completion Council state."""
8620
- state_file = _get_loki_dir() / "council" / "state.json"
8621
- if state_file.exists():
8622
- try:
8623
- return json.loads(state_file.read_text())
8624
- except Exception:
8625
- pass
8626
- return {"enabled": False, "total_votes": 0, "verdicts": []}
8637
+ return council_state(_get_loki_dir())
8627
8638
 
8628
8639
 
8629
8640
  @app.get("/api/council/verdicts", dependencies=[Depends(auth.require_scope("read"))])
@@ -8873,30 +8884,41 @@ async def get_context():
8873
8884
  # Notification Trigger API (v5.40.0)
8874
8885
  # =============================================================================
8875
8886
 
8876
- @app.get("/api/notifications", dependencies=[Depends(auth.require_scope("read"))])
8877
- async def get_notifications(
8878
- severity: Optional[str] = Query(None, pattern="^(critical|warning|info)$"),
8879
- unread_only: bool = Query(False),
8880
- ):
8881
- """Get notification list from .loki/notifications/active.json."""
8882
- loki_dir = _get_loki_dir()
8887
+ _EMPTY_NOTIFICATION_SUMMARY = {"total": 0, "unacknowledged": 0, "critical": 0, "warning": 0, "info": 0}
8888
+
8889
+
8890
+ def read_active_notifications(loki_dir: _Path) -> dict:
8891
+ """Read notifications/active.json, distinguishing "none yet" from "unreadable".
8892
+
8893
+ A missing active.json means there genuinely are no active notifications:
8894
+ the zero summary is real. A file that exists but fails to parse (torn
8895
+ write, corruption) means we do not know what is active -- summary must be
8896
+ None, not a fabricated all-zero summary that reads as "nothing active".
8897
+ """
8883
8898
  active_file = loki_dir / "notifications" / "active.json"
8884
8899
 
8885
8900
  if not active_file.exists():
8886
- return {
8887
- "notifications": [],
8888
- "summary": {"total": 0, "unacknowledged": 0, "critical": 0, "warning": 0, "info": 0},
8889
- }
8901
+ return {"notifications": [], "summary": dict(_EMPTY_NOTIFICATION_SUMMARY)}
8890
8902
 
8891
8903
  try:
8892
8904
  data = json.loads(active_file.read_text())
8893
8905
  except (json.JSONDecodeError, OSError):
8894
- return {
8895
- "notifications": [],
8896
- "summary": {"total": 0, "unacknowledged": 0, "critical": 0, "warning": 0, "info": 0},
8897
- }
8906
+ return {"notifications": None, "summary": None, "error": "unreadable_active_notifications"}
8898
8907
 
8899
- notifications = data.get("notifications", [])
8908
+ return {"notifications": data.get("notifications", []), "summary": data.get("summary", {})}
8909
+
8910
+
8911
+ @app.get("/api/notifications", dependencies=[Depends(auth.require_scope("read"))])
8912
+ async def get_notifications(
8913
+ severity: Optional[str] = Query(None, pattern="^(critical|warning|info)$"),
8914
+ unread_only: bool = Query(False),
8915
+ ):
8916
+ """Get notification list from .loki/notifications/active.json."""
8917
+ result = read_active_notifications(_get_loki_dir())
8918
+ notifications = result["notifications"]
8919
+
8920
+ if notifications is None:
8921
+ return result
8900
8922
 
8901
8923
  # Apply filters
8902
8924
  if severity:
@@ -8906,7 +8928,7 @@ async def get_notifications(
8906
8928
 
8907
8929
  return {
8908
8930
  "notifications": notifications,
8909
- "summary": data.get("summary", {}),
8931
+ "summary": result["summary"],
8910
8932
  }
8911
8933
 
8912
8934