loki-mode 9.55.0 → 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 (91) 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/SKILL.md +2 -2
  4. package/VERSION +1 -1
  5. package/autonomy/lib/proof-check.sh +9 -0
  6. package/autonomy/lib/proof-generator.py +12 -3
  7. package/autonomy/lib/workspace_diff.py +18 -7
  8. package/autonomy/loki +133 -3
  9. package/autonomy/run.sh +363 -19
  10. package/dashboard/__init__.py +1 -1
  11. package/dashboard/api_keys.py +12 -5
  12. package/dashboard/build_supervisor.py +25 -1
  13. package/dashboard/server.py +46 -24
  14. package/dashboard/static/index.html +57 -57
  15. package/docs/VERIFIED-COMPLETION-PLAN.md +7 -5
  16. package/docs/environment-variables.md +1 -0
  17. package/docs/v10/BACKLOG.md +33 -0
  18. package/docs/v10/BOARD.md +67 -10
  19. package/docs/v10/DECISIONS.md +43 -0
  20. package/docs/v10/PROGRESS.md +731 -0
  21. package/loki-ts/dist/loki.js +326 -325
  22. package/mcp/__init__.py +1 -1
  23. package/package.json +1 -1
  24. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  25. package/providers/claude.sh +1 -1
  26. package/providers/model_catalog.json +12 -5
  27. package/providers/models.sh +1 -1
  28. package/references/core-workflow.md +1 -1
  29. package/web-app/dist/assets/{AdminPage-CZrBkzRF.js → AdminPage-Oyx-w2Ch.js} +1 -1
  30. package/web-app/dist/assets/{Avatar-gQIp6FBI.js → Avatar-D60m65JB.js} +1 -1
  31. package/web-app/dist/assets/{Badge-D2_fBTFY.js → Badge-CqfZNaem.js} +1 -1
  32. package/web-app/dist/assets/{Button-D5qDd2ef.js → Button-BqjzE4fz.js} +1 -1
  33. package/web-app/dist/assets/CockpitPage-Czk7o_ou.js +47 -0
  34. package/web-app/dist/assets/ComparePage-BEfTrTb1.js +1 -0
  35. package/web-app/dist/assets/{ErrorBoundary-BRABI-BT.js → ErrorBoundary-BrLk5nkO.js} +1 -1
  36. package/web-app/dist/assets/EvidenceReceiptPanel-9vEpi5ir.js +1 -0
  37. package/web-app/dist/assets/{GitHubIssuesPanel-BC5GzKUN.js → GitHubIssuesPanel-D6izZx0w.js} +1 -1
  38. package/web-app/dist/assets/GitHubPRsPanel-BTkG2FgA.js +17 -0
  39. package/web-app/dist/assets/{HomePage-D_CDMoen.js → HomePage-nsr4kpN1.js} +2 -2
  40. package/web-app/dist/assets/{LoginPage-BBNA_N9t.js → LoginPage-BVblQrr9.js} +1 -1
  41. package/web-app/dist/assets/{MagicPage-CnFRZCMx.js → MagicPage-CmnSyLq7.js} +1 -1
  42. package/web-app/dist/assets/{MetricsPage-CPfLMRlh.js → MetricsPage-BVfwU81j.js} +1 -1
  43. package/web-app/dist/assets/{NotFoundPage-lA0XSGTz.js → NotFoundPage-AYQ6uqqI.js} +1 -1
  44. package/web-app/dist/assets/{ProjectPage-CNAPbr9z.js → ProjectPage-DMnbw9X9.js} +3 -3
  45. package/web-app/dist/assets/{ProjectsPage-CMtm8goN.js → ProjectsPage-CNVTAF2N.js} +1 -1
  46. package/web-app/dist/assets/{SettingsPage-BhIjVMYE.js → SettingsPage-D1_qasFD.js} +3 -3
  47. package/web-app/dist/assets/{ShowcasePage-BndtGeYo.js → ShowcasePage-2dBCjLXn.js} +1 -1
  48. package/web-app/dist/assets/{SystemSettingsPage-euXI-KXE.js → SystemSettingsPage-Djo6IE4u.js} +1 -1
  49. package/web-app/dist/assets/{TeamsPage-ClUKecun.js → TeamsPage-CY8p-yxD.js} +1 -1
  50. package/web-app/dist/assets/{TemplatesPage-BWHWVk0L.js → TemplatesPage-B6iFgH6t.js} +1 -1
  51. package/web-app/dist/assets/{TerminalOutput-Cv3N3Szf.js → TerminalOutput-B3yzZe-h.js} +1 -1
  52. package/web-app/dist/assets/{activity-dEhz-bXR.js → activity-CNfAjtRv.js} +1 -1
  53. package/web-app/dist/assets/{bell-B3XUMq1t.js → bell-CMETsHPH.js} +1 -1
  54. package/web-app/dist/assets/{bot-X-ssNlwR.js → bot-C9kXvk1i.js} +1 -1
  55. package/web-app/dist/assets/{check-C_9Nx7kb.js → check-BRK-zLB5.js} +1 -1
  56. package/web-app/dist/assets/{chevron-left-tvNswo3A.js → chevron-left-CxI5uD4g.js} +1 -1
  57. package/web-app/dist/assets/{circle-alert-CSB88nEs.js → circle-alert-CVFRPWpv.js} +1 -1
  58. package/web-app/dist/assets/{clock-C8p2e4NI.js → clock-CLYV8a_x.js} +1 -1
  59. package/web-app/dist/assets/{cloud-CKp2ZvhG.js → cloud-CiISiIOa.js} +1 -1
  60. package/web-app/dist/assets/{code-xml-dY70LCHD.js → code-xml-DHsgqwV-.js} +1 -1
  61. package/web-app/dist/assets/{database-DObn5fmn.js → database-DbvUqtz3.js} +1 -1
  62. package/web-app/dist/assets/{dollar-sign-DSu6n3K3.js → dollar-sign-Ct0lRPHw.js} +1 -1
  63. package/web-app/dist/assets/{file-code-corner-DpCnS73z.js → file-code-corner-D1sjFEh7.js} +1 -1
  64. package/web-app/dist/assets/{file-plus-D3IPKCf1.js → file-plus-DnmX18Fi.js} +1 -1
  65. package/web-app/dist/assets/{globe-C9QXkgH_.js → globe-bSpDNgoA.js} +1 -1
  66. package/web-app/dist/assets/{hammer-D_FtWe1y.js → hammer-Bj41Kvlq.js} +1 -1
  67. package/web-app/dist/assets/{index-CI54rara.css → index-BfQ0f3-S.css} +1 -1
  68. package/web-app/dist/assets/{index-Cz7q1bvC.js → index-CxajNJVu.js} +3 -3
  69. package/web-app/dist/assets/{layers-DsW7jnJX.js → layers-BNtBwYER.js} +1 -1
  70. package/web-app/dist/assets/{loader-circle-B3HspjKR.js → loader-circle-YY_VZofG.js} +1 -1
  71. package/web-app/dist/assets/{lock-DSI29pct.js → lock-rpjEPVoC.js} +1 -1
  72. package/web-app/dist/assets/{package-CLNxtA2x.js → package-desXU6XA.js} +1 -1
  73. package/web-app/dist/assets/{plus-DyI4WC4_.js → plus-7AoOU-I9.js} +1 -1
  74. package/web-app/dist/assets/{refresh-cw-D2dv0xA0.js → refresh-cw-oNj-Xn4B.js} +1 -1
  75. package/web-app/dist/assets/{rotate-ccw-CBvihA-a.js → rotate-ccw-DeiFjfVO.js} +1 -1
  76. package/web-app/dist/assets/{scroll-text-DvoOvAl_.js → scroll-text-Q9AzWiNV.js} +1 -1
  77. package/web-app/dist/assets/{server-DenWvSUt.js → server-DKgVwfek.js} +1 -1
  78. package/web-app/dist/assets/{shield-alert-40cX_FvG.js → shield-alert-G6x-bS-I.js} +1 -1
  79. package/web-app/dist/assets/{thumbs-up-BrjZ6QuV.js → thumbs-up-Qhk0yvw0.js} +1 -1
  80. package/web-app/dist/assets/{trash-2-C6evwkEH.js → trash-2-D23gvznl.js} +1 -1
  81. package/web-app/dist/assets/{trending-down-CfbZis3O.js → trending-down-CSQClPrr.js} +1 -1
  82. package/web-app/dist/assets/{trending-up-DWuEK3WC.js → trending-up-sjMSd1wc.js} +1 -1
  83. package/web-app/dist/assets/{upload-B9DFBcPG.js → upload-Dchovhld.js} +1 -1
  84. package/web-app/dist/assets/{usePolling-BryVC-T1.js → usePolling-DwLceXwF.js} +1 -1
  85. package/web-app/dist/assets/{user-DMRXwZiw.js → user-BqZM8CmD.js} +1 -1
  86. package/web-app/dist/index.html +2 -2
  87. package/web-app/server.py +52 -17
  88. package/web-app/dist/assets/CockpitPage-DVMk2RUK.js +0 -47
  89. package/web-app/dist/assets/ComparePage-NMLyE1xL.js +0 -1
  90. package/web-app/dist/assets/EvidenceReceiptPanel-D2LNKqUx.js +0 -1
  91. package/web-app/dist/assets/GitHubPRsPanel-BSlmoVTS.js +0 -17
package/autonomy/run.sh CHANGED
@@ -5338,6 +5338,62 @@ except Exception:
5338
5338
  return 0
5339
5339
  }
5340
5340
 
5341
+ #===============================================================================
5342
+ # Rule of Two (moat P9): a GitHub token never reaches an agent session.
5343
+ #
5344
+ # The run reads untrusted issue text and spawns agents that act on it, so a
5345
+ # token in the exported environment is one prompt injection away from a push.
5346
+ # main() un-exports every GitHub token it inherited (the shell keeps the value).
5347
+ # Every provider, reviewer, helper and test the agent writes then starts without
5348
+ # it. Loki's own trusted GitHub calls get it back for the one command:
5349
+ # - direct `gh ...` calls, through the gh() wrapper defined below;
5350
+ # - _loki_with_github_tokens <cmd...>, for the post-session push/PR paths.
5351
+ # LOKI_ALLOW_AGENT_GITHUB_TOKEN=1 (exact value) restores the old inheritance
5352
+ # and prints one stderr warning that the agent holds the token.
5353
+ # This is hygiene against a naive injection, not an isolation boundary: code
5354
+ # running as the same user can still read the parent's environment block
5355
+ # (/proc/<pid>/environ on Linux, sudo on a hosted runner). The boundary is a
5356
+ # CI job that holds no write token while the agent runs (see
5357
+ # .github/workflows/loki-issue-to-pr.yml).
5358
+ #===============================================================================
5359
+ _LOKI_WITHHELD_TOKENS=""
5360
+
5361
+ _loki_with_github_tokens() {
5362
+ local _v _rc=0
5363
+ for _v in $_LOKI_WITHHELD_TOKENS; do export "${_v?}"; done
5364
+ "$@" || _rc=$?
5365
+ for _v in $_LOKI_WITHHELD_TOKENS; do export -n "${_v?}"; done
5366
+ return "$_rc"
5367
+ }
5368
+
5369
+ _loki_withhold_github_tokens() {
5370
+ local _v _held=""
5371
+ # Operator opt-out (exact value 1): keep the earlier behavior, where the
5372
+ # agent inherits the token. That is a Rule of Two exposure, so say so.
5373
+ if [ "${LOKI_ALLOW_AGENT_GITHUB_TOKEN:-}" = "1" ]; then
5374
+ for _v in GH_TOKEN GITHUB_TOKEN GH_ENTERPRISE_TOKEN GITHUB_ENTERPRISE_TOKEN; do
5375
+ [ -n "${!_v:-}" ] && _held="${_held:+$_held }$_v"
5376
+ done
5377
+ [ -z "$_held" ] || printf '%s\n' "WARNING: LOKI_ALLOW_AGENT_GITHUB_TOKEN=1: the agent session holds the GitHub token ($_held); an injected prompt can push with it (Rule of Two exposure)." >&2
5378
+ return 0
5379
+ fi
5380
+ for _v in GH_TOKEN GITHUB_TOKEN GH_ENTERPRISE_TOKEN GITHUB_ENTERPRISE_TOKEN; do
5381
+ [ -n "${!_v:-}" ] || continue
5382
+ export -n "${_v?}"
5383
+ case " $_LOKI_WITHHELD_TOKENS " in
5384
+ *" $_v "*) ;;
5385
+ *) _LOKI_WITHHELD_TOKENS="${_LOKI_WITHHELD_TOKENS:+$_LOKI_WITHHELD_TOKENS }$_v" ;;
5386
+ esac
5387
+ done
5388
+ [ -n "$_LOKI_WITHHELD_TOKENS" ] || return 0
5389
+ # Only when the binary exists, so `command -v gh` keeps meaning "gh is
5390
+ # installed" for every caller that checks it.
5391
+ if command -v gh >/dev/null 2>&1; then
5392
+ gh() { _loki_with_github_tokens command gh "$@"; }
5393
+ fi
5394
+ log_info "Withheld from agent sessions (Rule of Two): $_LOKI_WITHHELD_TOKENS. Loki's own push and PR steps still use it."
5395
+ }
5396
+
5341
5397
  #===============================================================================
5342
5398
  # on_run_complete (Slice 3: opt-in local git output on success)
5343
5399
  #
@@ -5403,11 +5459,14 @@ on_run_complete() {
5403
5459
  # timeout is not installed (a local wrapper keeps this set -u safe on bash
5404
5460
  # 3.2, where an empty array expansion would error). Keeps every existing
5405
5461
  # `|| true` non-fatal behavior.
5462
+ # The push and gh calls below are the trusted post-session step, so they
5463
+ # get the withheld GitHub token back (a plain `timeout 30 gh` would exec
5464
+ # the binary and bypass the gh() wrapper).
5406
5465
  _loki_net() {
5407
5466
  if command -v timeout >/dev/null 2>&1; then
5408
- timeout 30 "$@"
5467
+ _loki_with_github_tokens timeout 30 "$@"
5409
5468
  else
5410
- "$@"
5469
+ _loki_with_github_tokens "$@"
5411
5470
  fi
5412
5471
  }
5413
5472
  # Require gh + auth.
@@ -9180,6 +9239,12 @@ setup_agent_branch() {
9180
9239
  log_warn "Left uncommitted from an earlier unfinished session, now treated as your files: $leftover"
9181
9240
  fi
9182
9241
  rm -f .loki/state/session-created.z 2>/dev/null
9242
+ # BACKLOG 130(c): seal the now-empty session-created.z record so this
9243
+ # fresh mint's OWN in-process memory of "absent" is what every later
9244
+ # record call and commit_session_changes verify against for the rest of
9245
+ # this run -- not the file's on-disk content, which an agent process could
9246
+ # otherwise repopulate with a planted path before this run ever trusts it.
9247
+ _loki_session_created_seal # BACKLOG-130C-MINT-SEAL
9183
9248
  # BACKLOG 68 rework: a fresh mint starts with no tracked-since anchor and no
9184
9249
  # in-flight marker (never seed the anchor from session-start-sha -- an
9185
9250
  # anchor==start would let a later resume's "anchor..HEAD" diff include this
@@ -9236,6 +9301,40 @@ _loki_snapshot_or_fail_closed() {
9236
9301
  # ~9165) so that path is never reported as tampering.
9237
9302
  _LOKI_SNAPSHOT_SEAL=""
9238
9303
 
9304
+ # BACKLOG 130(c): .loki/state/session-created.z is NOT one of the two files
9305
+ # _LOKI_SNAPSHOT_SEAL above covers. It records the paths THIS session created
9306
+ # (unioned after every provider turn by _loki_record_session_created), and
9307
+ # _loki_snapshot_preexisting's resume union (line ~9683) trusts its content
9308
+ # directly: any path it lists is EXCLUDED from the new preexisting-untracked.z
9309
+ # snapshot, i.e. treated as "the session's own, never the user's". An agent
9310
+ # that plants a user's file path into session-created.z therefore gets that
9311
+ # exact path permanently exempted from pre-existing-file protection on the
9312
+ # next resume -- the file is swept into `git add -A` and the session commit as
9313
+ # if the session had created it. A separate seal, sibling to
9314
+ # _LOKI_SNAPSHOT_SEAL, closes this WITHIN one process's lifetime: sealed right
9315
+ # after every legitimate write this process makes (a successful record, the
9316
+ # fresh-mint delete, and this process's first look at the file on resume),
9317
+ # verified before any LATER point in the SAME process trusts the content,
9318
+ # fail-closed (delete the file, clear the seal) on a mismatch. Kept SEPARATE
9319
+ # from _LOKI_SNAPSHOT_SEAL rather than folded into one combined digest: this
9320
+ # file legitimately changes every provider turn, so a shared digest would
9321
+ # falsely flag tampering on every normal turn, and the fresh-mint `rm` (line
9322
+ # ~9241) runs after preexisting-untracked.z is sealed, which would falsely
9323
+ # flag every fresh mint too.
9324
+ # ACCEPTED GAP (disclosure, not prevention, same bar as BACKLOG 70's own): a
9325
+ # seal lives only in this process's memory, so it cannot verify content a
9326
+ # PRIOR process wrote before this one starts (setup_agent_branch's resume path
9327
+ # seals its own first read of the file rather than verifying it, since no
9328
+ # prior seal survives to check against) -- tampering already on disk before
9329
+ # this process begins is trusted at that one read, same as
9330
+ # _LOKI_SNAPSHOT_SEAL's own resume-time trust of preexisting-untracked.z's
9331
+ # prior content. What this DOES close: an agent process co-located with THIS
9332
+ # run can no longer plant a path into session-created.z between this run's own
9333
+ # writes and its own later reads (the resume union's exclude use, the next
9334
+ # record's merge, or the commit-time gate) without it being caught before that
9335
+ # content is trusted.
9336
+ _LOKI_SESSION_CREATED_SEAL=""
9337
+
9239
9338
  # BACKLOG 129 (follow-up to BACKLOG 70/BACKLOG 70-awk): the DIGEST computation
9240
9339
  # above is hardened (fixed-root-path-first, no awk), but until this function
9241
9340
  # was added the snapshot WRITER (_loki_untracked_merge) and READER
@@ -9421,11 +9520,15 @@ _loki_snapshot_hash_tool() {
9421
9520
  # which a user-writable directory ahead of the system dirs (e.g. Homebrew's
9422
9521
  # prefix on macOS) can shadow -- not fully closed, only narrowed to hosts
9423
9522
  # lacking the standard tool paths; the python3 fallback below, kept only for
9424
- # the extremely rare host with neither tool anywhere, is not proof against the
9425
- # .pth class this function exists to close -- that path additionally hardens
9426
- # with -I (isolated mode: also disables user site-packages, stronger than -E)
9427
- # but remains narrower than the shasum/sha256sum path on a host where
9428
- # Homebrew's system site-packages is itself user-writable.
9523
+ # the extremely rare host with neither tool anywhere, is resolved via
9524
+ # _loki_snapshot_py_tool and run with -I -S (BACKLOG 131(b) -- see the comment
9525
+ # at that fallback for why -S, not just -I, is required to close the .pth
9526
+ # class), so it carries the same fixed-root-path-first resolution and
9527
+ # site-packages exclusion as the other hardened call sites in this file. It
9528
+ # still inherits _loki_snapshot_py_tool's own accepted gaps (documented at
9529
+ # that function): replacing the interpreter AT ONE OF ITS FIXED PATHS requires
9530
+ # root, and its own PATH-walk fallback still trusts whatever sits first on an
9531
+ # absolute PATH entry on a host lacking those fixed paths.
9429
9532
  _loki_snapshot_digest() {
9430
9533
  local tool
9431
9534
  tool="$(_loki_snapshot_hash_tool)" || tool=""
@@ -9464,10 +9567,17 @@ _loki_snapshot_digest() {
9464
9567
  return 0
9465
9568
  fi
9466
9569
  # Fallback: neither sha256sum nor shasum found anywhere checked above
9467
- # (extremely rare on macOS/Linux). -I is isolated mode: stronger than -E,
9468
- # also disables user site-packages, but this remains narrower than the
9469
- # tool-based path above on a host whose Python ships a hijackable system
9470
- # site-packages outside the user's control (see comment above). Additional
9570
+ # (extremely rare on macOS/Linux). Resolved via _loki_snapshot_py_tool
9571
+ # (fixed root-owned path first) and run with -I -S, the same BACKLOG 129
9572
+ # pattern used at _loki_untracked_merge / _loki_covered_paths: -I alone is
9573
+ # NOT enough -- a reviewer reproduced, on a sibling function in this same
9574
+ # tamper-detection system, that a .pth file planted in Homebrew python3's
9575
+ # own (user-writable) site-packages directory still fires under `python3
9576
+ # -I` alone, since -I implies -s (skip user site-packages) but does not
9577
+ # skip the resolved interpreter's OWN site-packages. Only -S (skip ALL
9578
+ # site-packages, including the interpreter's own) closes that. A resolve
9579
+ # failure degrades this fallback to printing nothing, same as any other
9580
+ # failure path here (caller treats empty as "could not seal"). Additional
9471
9581
  # accepted gap specific to this fallback (not present on the tool-based
9472
9582
  # path above, which distinguishes "-" from "?"): OSError covers BOTH
9473
9583
  # "file does not exist" and "file exists but is unreadable" here, so an
@@ -9478,7 +9588,9 @@ _loki_snapshot_digest() {
9478
9588
  # for present-but-unreadable) is straightforward but left undone here: this
9479
9589
  # fallback only runs on a host with neither sha256sum nor shasum anywhere,
9480
9590
  # already the rare case this whole function treats as a residual gap.
9481
- python3 -I -c 'import sys, hashlib, os
9591
+ local pytool=""
9592
+ pytool="$(_loki_snapshot_py_tool)" || return 1
9593
+ "$pytool" -I -S -c 'import sys, hashlib
9482
9594
  sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9483
9595
  out = []
9484
9596
  for path in sys.argv[1:]:
@@ -9491,6 +9603,155 @@ print(" ".join(out))' \
9491
9603
  ".loki/state/preexisting-untracked.z" ".loki/state/preexisting-untracked.sha.z" 2>/dev/null
9492
9604
  }
9493
9605
 
9606
+ # _loki_session_created_digest: sha256 of session-created.z alone ("-" for
9607
+ # absent, "?" for present-but-unreadable/unhashable, same sentinel contract as
9608
+ # _loki_snapshot_digest above). Deliberately a thin sibling, not a parameterized
9609
+ # reuse of _loki_snapshot_digest: that function's exact text (the `for path in
9610
+ # ...` line and the python fallback's trailing argv line) is matched by
9611
+ # tests/test-branch-lifecycle.sh's RED mutation harness, so leaving it
9612
+ # untouched keeps that harness's anchors valid. Resolved via the SAME hardened
9613
+ # tool resolvers (_loki_snapshot_hash_tool, _loki_snapshot_py_tool) as the
9614
+ # sibling above -- no fresh/unhardened python3 or shasum call site.
9615
+ _loki_session_created_digest() {
9616
+ local tool
9617
+ tool="$(_loki_snapshot_hash_tool)" || tool=""
9618
+ local path=".loki/state/session-created.z"
9619
+ # A FIFO or ANY symlink at $path (dangling or not -- [ -L ] rejects both,
9620
+ # deliberately broader than just the dangling case: the only way this
9621
+ # file is ever legitimately written is _loki_untracked_merge's own
9622
+ # write-to-.tmp-then-`mv -f`, which always leaves a plain regular file at
9623
+ # $path, never a symlink, so a symlink here is never this session's own
9624
+ # legitimate output) is neither absent ([ -f ] is false for both) nor a
9625
+ # hashable regular file -- falling through to the absent-file branch below
9626
+ # would print the same "-" sentinel every fresh mint already seals as its
9627
+ # normal starting state, silently trusting an agent-planted FIFO (whose
9628
+ # blocked writer feeds content into the merge's later open() call) or a
9629
+ # dangling symlink (whose target read fails and is treated as "-" too).
9630
+ # Caught here, before either digest branch, with a sentinel ("?") that can
9631
+ # never equal "-", a real hex digest, or empty.
9632
+ if [ -L "$path" ] || { [ -e "$path" ] && [ ! -f "$path" ]; }; then printf '%s\n' "?"; return 0; fi # BACKLOG-130C-NONREG-CHECK
9633
+ if [ -n "$tool" ]; then
9634
+ local h=""
9635
+ if [ -f "$path" ]; then
9636
+ case "${tool##*/}" in
9637
+ shasum) h="$("$tool" -a 256 -- "$path" 2>/dev/null)" || h="" ;;
9638
+ *) h="$("$tool" -- "$path" 2>/dev/null)" || h="" ;;
9639
+ esac
9640
+ h="${h%% *}"
9641
+ if [ "${#h}" -eq 64 ]; then
9642
+ case "$h" in
9643
+ *[!0123456789abcdef]*) h="?" ;;
9644
+ esac
9645
+ else
9646
+ h="?"
9647
+ fi
9648
+ printf '%s\n' "$h"
9649
+ else
9650
+ printf '%s\n' "-"
9651
+ fi
9652
+ return 0
9653
+ fi
9654
+ # Fallback: neither sha256sum nor shasum found anywhere (same rare-host
9655
+ # case _loki_snapshot_digest's own fallback documents). Resolved via
9656
+ # _loki_snapshot_py_tool, run with -I -S (see that function's comment for
9657
+ # why -S, not just -I, is required to close the .pth vector).
9658
+ local pytool=""
9659
+ pytool="$(_loki_snapshot_py_tool)" || return 1
9660
+ "$pytool" -I -S -c 'import sys, hashlib
9661
+ sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9662
+ try:
9663
+ with open(sys.argv[1], "rb") as fh:
9664
+ print(hashlib.sha256(fh.read()).hexdigest())
9665
+ except OSError:
9666
+ print("-")' \
9667
+ "$path" 2>/dev/null
9668
+ }
9669
+
9670
+ # _loki_session_created_seal: record this process's own memory of the current
9671
+ # session-created.z content, right after every point that legitimately changes
9672
+ # it (a successful union in _loki_record_session_created, the fresh-mint
9673
+ # delete, a resume, and the post-commit/no-op delete in
9674
+ # commit_session_changes). Same fail-closed shape as _loki_snapshot_seal: an
9675
+ # empty or "?" digest at seal time is itself suspicious (a legitimate write or
9676
+ # delete should never immediately be unreadable), so it is rejected rather
9677
+ # than sealed, which would let seal and a later verify silently agree on the
9678
+ # sentinel and disarm the guard for the rest of the session.
9679
+ _loki_session_created_seal() {
9680
+ local d
9681
+ d="$(_loki_session_created_digest)"
9682
+ case "$d" in
9683
+ ""|*'?'*)
9684
+ # Fail closed, not just "untrusted": an empty seal makes
9685
+ # _loki_session_created_verify's `[ -n "$_LOKI_SESSION_CREATED_SEAL" ]
9686
+ # || return 0` skip every later check for the rest of this
9687
+ # process's life. Leaving the file on disk here would fail OPEN --
9688
+ # unreadable-but-present content would then be silently trusted by
9689
+ # every later read site with no seal ever able to catch it. Delete
9690
+ # it instead, matching _loki_session_created_verify's own
9691
+ # fail-closed action on a real mismatch -- and, like that function's
9692
+ # own re-seal after its delete, set the seal to the digest's real
9693
+ # "absent" sentinel ("-"), not empty: an EMPTY seal here re-arms
9694
+ # nothing (the guard above short-circuits as "never sealed" on
9695
+ # every later check for the rest of this process), so an agent that
9696
+ # recreates the file with a planted path in this window would get
9697
+ # it silently merged in as trusted on the next successful record.
9698
+ # "-" is a real, non-empty seal value that the next verify's live
9699
+ # digest of a freshly-recreated file cannot match, keeping the
9700
+ # guard armed against exactly that recreation.
9701
+ rm -f .loki/state/session-created.z 2>/dev/null
9702
+ _LOKI_SESSION_CREATED_SEAL="-"
9703
+ log_warn "Could not seal the session-created record (hash tool failed or produced no readable digest); discarding it (its files are treated as yours, left uncommitted)"
9704
+ audit_log "SESSION_CREATED_SEAL_FAILED" "_loki_session_created_digest returned empty or an unreadable-file sentinel at seal time"
9705
+ ;;
9706
+ *)
9707
+ _LOKI_SESSION_CREATED_SEAL="$d"
9708
+ ;;
9709
+ esac
9710
+ }
9711
+
9712
+ # _loki_session_created_verify: compare the live session-created.z digest
9713
+ # against the sealed one. No seal recorded (never sealed this process, or
9714
+ # deliberately cleared) is not tampering: returns success, does nothing --
9715
+ # EVERY caller that trusts session-created.z's content must therefore have
9716
+ # sealed it first (mint, resume, and after each successful record all do). A
9717
+ # seal that no longer matches IS tampering: logs and discloses it (same
9718
+ # DISCLOSURE bar as BACKLOG 70, not prevention against a same-UID process), and
9719
+ # fails closed by deleting the untrusted file outright -- its entries then
9720
+ # become "the user's, not recorded as this session's", the safe direction,
9721
+ # since a false claim of user ownership only leaves a file uncommitted on disk,
9722
+ # never deletes it, while a false claim of session ownership can get a user's
9723
+ # file swept into a commit and removed from disk on a later checkout of the
9724
+ # base. Called BEFORE any later point in the SAME process treats the file's
9725
+ # content as trustworthy (verify-before-trust, not verify-after-use):
9726
+ # _loki_record_session_created verifies at its very top, before its own
9727
+ # self-referential merge (base="$out"), and unconditionally -- not gated on
9728
+ # _LOKI_SNAPSHOT_THIS_RUN, since an interrupt/cleanup call can still fire after
9729
+ # that flag was cleared mid-session; commit_session_changes verifies at its
9730
+ # very top, before every one of its several early returns (the untrack
9731
+ # failure, the git 2.25 unstage failure, a secret-scan abort, a failed `git
9732
+ # commit`) that would otherwise skip its own rm -f cleanup and leave a
9733
+ # tampered file on disk for the next resume. _loki_resume_snapshot does NOT
9734
+ # call this function: it is this process's FIRST read of a file the PREVIOUS
9735
+ # (dead) process wrote, so there is no seal from that process to verify
9736
+ # against here; it instead calls _loki_session_created_seal directly to
9737
+ # establish THIS process's own baseline before anything (including
9738
+ # _loki_snapshot_preexisting's resume-union exclude use, right after) reads
9739
+ # the file. See the ACCEPTED GAP note at _LOKI_SESSION_CREATED_SEAL above.
9740
+ _loki_session_created_verify() {
9741
+ local live="" path=".loki/state/session-created.z"
9742
+ [ -n "$_LOKI_SESSION_CREATED_SEAL" ] || return 0
9743
+ live="$(_loki_session_created_digest)"
9744
+ if [ "$live" = "$_LOKI_SESSION_CREATED_SEAL" ]; then
9745
+ return 0
9746
+ fi
9747
+ log_warn "session-created.z changed after this session recorded it (possible tampering by an agent process); discarding the record (its files are treated as yours, left uncommitted)"
9748
+ audit_log "SESSION_CREATED_TAMPERED" "session-created.z changed after being sealed: live=$live,sealed=$_LOKI_SESSION_CREATED_SEAL"
9749
+ rm -f "$path" 2>/dev/null
9750
+ _LOKI_SESSION_CREATED_SEAL=""
9751
+ _loki_session_created_seal
9752
+ return 1
9753
+ }
9754
+
9494
9755
  # _loki_snapshot_seal: record this process's own memory of what it just wrote.
9495
9756
  # Called only right after a successful _loki_snapshot_preexisting. Minor
9496
9757
  # reviewer note: a legitimate snapshot write should never hit an unreadable-
@@ -9556,10 +9817,13 @@ _loki_snapshot_verify() {
9556
9817
  if [ "$live" = "$_LOKI_SNAPSHOT_SEAL" ]; then
9557
9818
  return 0
9558
9819
  fi
9820
+ log_warn "Pre-existing-file snapshot or its hash file changed after this session recorded it (possible tampering by an agent process); treating this session as unsnapshotted (nothing will be committed)"
9821
+ # Record both digests (before clearing the seal below) so an operator
9822
+ # reading the audit log can tell a genuine content tamper apart from a
9823
+ # transient hash-tool failure that just produced a different digest.
9824
+ audit_log "SNAPSHOT_TAMPERED" "preexisting-untracked.z or .sha.z changed after being sealed: live=$live,sealed=$_LOKI_SNAPSHOT_SEAL"
9559
9825
  _LOKI_SNAPSHOT_SEAL=""
9560
9826
  _LOKI_SNAPSHOT_THIS_RUN=0
9561
- log_warn "Pre-existing-file snapshot or its hash file changed after this session recorded it (possible tampering by an agent process); treating this session as unsnapshotted (nothing will be committed)"
9562
- audit_log "SNAPSHOT_TAMPERED" "preexisting-untracked.z or .sha.z changed after being sealed"
9563
9827
  mkdir -p .loki/state 2>/dev/null
9564
9828
  : > .loki/state/preexisting-untracked.failed 2>/dev/null
9565
9829
  return 1
@@ -9844,6 +10108,36 @@ _loki_advance_tracked_since_anchor() {
9844
10108
  # disk, not committed).
9845
10109
  _loki_record_session_created() {
9846
10110
  local snap=".loki/state/preexisting-untracked.z" out=".loki/state/session-created.z"
10111
+ # BACKLOG 130(c): verify session-created.z BEFORE this run's THIS_RUN gate
10112
+ # or its own merge trust the file -- verify-before-trust, not verify-after-
10113
+ # use. Runs unconditionally (not gated on THIS_RUN=1): an interrupt/cleanup
10114
+ # call can still fire after THIS_RUN was cleared mid-session (e.g. by
10115
+ # _loki_snapshot_verify below on an earlier turn), and that call must not
10116
+ # skip this check just because the gate below is about to return early. On
10117
+ # a mismatch the file is already deleted by the time this returns (fail
10118
+ # closed inside _loki_session_created_verify itself, not via this return
10119
+ # value alone -- every call site of this function ignores its return with
10120
+ # `|| true`, same as _loki_snapshot_verify above); the merge below then
10121
+ # recomputes cleanly from a clean slate (base="$out" is now absent), so a
10122
+ # legitimate record right after a caught tamper still records this turn's
10123
+ # real files instead of hard-failing the whole call.
10124
+ # ACCEPTED GAP (verify-to-use window, disclosed here since ordering is the
10125
+ # whole point of this fix): this verify call and the merge's own read of
10126
+ # "$out" as `base` (inside _loki_untracked_merge, below) are not atomic --
10127
+ # _loki_advance_tracked_since_anchor, a git rev-parse, and a whole-repo
10128
+ # `git status` all run in between. A same-UID write landing in that window
10129
+ # is merged, then sealed as trusted by the seal call after a successful
10130
+ # merge. This is the SAME shape of gap _LOKI_SNAPSHOT_SEAL's own
10131
+ # verify/merge pair already accepts for preexisting-untracked.z (see
10132
+ # _loki_untracked_merge's own BACKLOG 129 comment). Closing it would mean
10133
+ # re-verifying again immediately before _loki_untracked_merge's read, inside
10134
+ # that function -- deliberately not done here: _loki_untracked_merge's exact
10135
+ # text is what tests/test-branch-lifecycle.sh's RED mutation harness matches
10136
+ # by name-anchor, and threading a second, narrower verify through its
10137
+ # shared codepath (also called with mode=exact for the OTHER snapshot) risks
10138
+ # that anchor for a gap this narrow. Disclosure, not full prevention, is the
10139
+ # same bar BACKLOG 70 itself accepts.
10140
+ _loki_session_created_verify || true
9847
10141
  [ "${_LOKI_SNAPSHOT_THIS_RUN:-0}" = 1 ] || return 0
9848
10142
  # BACKLOG 70: detect tampering with the sealed snapshot as early as
9849
10143
  # possible (this runs after every provider turn, not just at commit time).
@@ -9857,6 +10151,11 @@ _loki_record_session_created() {
9857
10151
  if [ -f "$snap" ] && _loki_untracked_status "$out.status" \
9858
10152
  && _loki_untracked_merge "$out.status" "$out" "$snap" cover "$out"; then
9859
10153
  rm -f "$out.status"
10154
+ # BACKLOG 130(c): seal this process's own memory of the record it just
10155
+ # wrote, so the NEXT call to this function (or commit_session_changes)
10156
+ # verifies against what THIS process actually wrote, not whatever an
10157
+ # agent process may write to the file in between.
10158
+ _loki_session_created_seal
9860
10159
  return 0
9861
10160
  fi
9862
10161
  rm -f "$out.status" "$out.tmp" 2>/dev/null
@@ -9868,6 +10167,19 @@ _loki_record_session_created() {
9868
10167
  # from an unfinished previous session (this session's commit includes them).
9869
10168
  _loki_resume_snapshot() {
9870
10169
  local carried=""
10170
+ # BACKLOG 130(c): seal THIS process's own first look at session-created.z
10171
+ # before anything below reads or trusts it (verify-before-trust). This is
10172
+ # a resuming process picking up a file the PREVIOUS (now-dead) process
10173
+ # wrote; there is no seal from that process to verify against here (a
10174
+ # seal never survives past the process that set it) -- sealing now is what
10175
+ # lets THIS process detect an agent tampering with the file for the rest
10176
+ # of ITS OWN lifetime (every subsequent record call, and the commit-time
10177
+ # check), the same disclosure bar BACKLOG 70 already accepts for the
10178
+ # sibling snapshot: tampering already on disk before this process starts
10179
+ # is an accepted gap, never silently trusted forever after. Unconditional
10180
+ # (not gated on the union below succeeding): the union does not modify
10181
+ # session-created.z, so this process's view of it is stable either way.
10182
+ _loki_session_created_seal
9871
10183
  if ! _loki_snapshot_or_fail_closed union; then
9872
10184
  log_warn "Could not add your current untracked files to the pre-existing list; this session will commit nothing (review and commit manually)"
9873
10185
  return 0
@@ -10028,6 +10340,18 @@ commit_session_changes() {
10028
10340
  command -v git >/dev/null 2>&1 || return 0
10029
10341
  git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
10030
10342
 
10343
+ # BACKLOG 130(c): verify session-created.z BEFORE any of this function's
10344
+ # several early returns below (the untrack failure, the git 2.25 unstage
10345
+ # failure, the secret-scan abort, a failed `git commit`) -- every one of
10346
+ # them skips the two rm -f .loki/state/session-created.z cleanup sites
10347
+ # further down, so a tampered file left on disk by any of those paths
10348
+ # would otherwise survive untouched into the next session's resume union.
10349
+ # Placed at the very top, before even the branch-protection/PARALLEL_MODE/
10350
+ # branch-name checks below, so no early return in this function can skip
10351
+ # it. On a mismatch the file is deleted here already; nothing downstream
10352
+ # needs its own check.
10353
+ _loki_session_created_verify || true # BACKLOG-130C-COMMIT-TOP-VERIFY
10354
+
10031
10355
  # Only act when a session feature branch was set up. This preserves the
10032
10356
  # LOCK A1 opt-out contract (LOKI_BRANCH_PROTECTION=false -> no agent-branch.txt
10033
10357
  # -> we never commit on the user's own branch), and also no-ops the detached
@@ -13283,14 +13607,26 @@ TREOF
13283
13607
  # summary the old best-effort parse of the tail still applies (null when it
13284
13608
  # finds nothing). Either way a recorded count above zero is a failure: the
13285
13609
  # council evidence gate and the Bun gate both read failed_count > 0 as one.
13610
+ # BACKLOG 111: pytest's summary line also reports a distinct "error" outcome
13611
+ # for a fixture/collection/setup error ("1 passed, 1 error in 0.5s"), which
13612
+ # is neither "passed" nor "failed" text, so the [0-9]+ failed match alone
13613
+ # found nothing and printed n=0 even though seen=1 -- a real error gated
13614
+ # PASS. pytest pluralizes it ("2 errors"), so match errors? and ADD it to
13615
+ # the failed count on that line ("2 failed, 3 errors" is 5 distinct broken
13616
+ # tests, not 3 -- failed and error are independent outcomes within one
13617
+ # summary line, unlike the separate cross-line max below, which exists to
13618
+ # stop a later green summary from hiding an earlier failed run).
13286
13619
  # LC_ALL=C: bytes, so any runner output parses; [ \t] not [[:space:]] for
13287
13620
  # old mawk.
13288
13621
  local _tr_passed_n _tr_failed_n _tr_summary_red=false
13289
13622
  _tr_failed_n=$(printf '%s\n' "${output:-}" | LC_ALL=C sed "s/$(printf '\033')\[[0-9;]*[A-Za-z]//g" | LC_ALL=C awk '
13290
13623
  function upd(n) { if (n > f) f = n; seen = 1 }
13291
13624
  /^Tests:[ \t]/ || /^[ \t]*Tests[ \t]+[0-9]/ ||
13292
- /^=+ .*[0-9]+ (passed|failed)/ || /^[0-9]+ (passed|failed).* in [0-9.]+s/ {
13293
- n = 0; if (match($0, /[0-9]+ failed/)) n = substr($0, RSTART, RLENGTH) + 0; upd(n) }
13625
+ /^=+ .*[0-9]+ (passed|failed|errors?)/ || /^[0-9]+ (passed|failed|errors?).* in [0-9.]+s/ {
13626
+ n = 0
13627
+ if (match($0, /[0-9]+ failed/)) n = substr($0, RSTART, RLENGTH) + 0
13628
+ if (match($0, /[0-9]+ errors?/)) n += substr($0, RSTART, RLENGTH) + 0
13629
+ upd(n) }
13294
13630
  /^[^ A-Za-z0-9]+ fail [0-9]+[ \t]*$/ { if (prev ~ /^[^ A-Za-z0-9]+ pass [0-9]+[ \t]*$/) upd($3 + 0) }
13295
13631
  /^[ \t]*[0-9]+ passing \(/ { upd(0) }
13296
13632
  /^[ \t]*[0-9]+ failing[ \t]*$/ { upd($1 + 0) }
@@ -13643,8 +13979,8 @@ if not isinstance(d, dict):
13643
13979
  verdict = d.get('verdict', 'unknown')
13644
13980
  diff = d.get('diff', {}) if isinstance(d.get('diff'), dict) else {}
13645
13981
  tests = d.get('tests', {}) if isinstance(d.get('tests'), dict) else {}
13646
- diff_ok = diff.get('ok')
13647
- tests_ok = tests.get('ok')
13982
+ diff_ok = 'inconclusive' if diff.get('inconclusive') else diff.get('ok')
13983
+ tests_ok = 'inconclusive' if tests.get('inconclusive') else tests.get('ok')
13648
13984
  runner = tests.get('runner', 'none')
13649
13985
  parts = ['verdict=%s' % verdict]
13650
13986
  parts.append('diff_ok=%s' % diff_ok)
@@ -27428,6 +27764,8 @@ except (json.JSONDecodeError, OSError): pass
27428
27764
  main() {
27429
27765
  _loki_install_signal_traps
27430
27766
  SESSION_START_EPOCH=$(date +%s)
27767
+ # Before anything can spawn a provider (capability probes included).
27768
+ _loki_withhold_github_tokens
27431
27769
 
27432
27770
  # First-run disclosure (shown once, before any work; best-effort).
27433
27771
  if type loki_show_disclosure_once &>/dev/null; then
@@ -27596,6 +27934,10 @@ main() {
27596
27934
 
27597
27935
  # Handle background mode
27598
27936
  if [ "$BACKGROUND_MODE" = "true" ]; then
27937
+ # The relaunched runner withholds the tokens itself; it needs them in
27938
+ # its environment to do so. This process exits right after launching.
27939
+ local _bg_tok
27940
+ for _bg_tok in $_LOKI_WITHHELD_TOKENS; do export "${_bg_tok?}"; done
27599
27941
  # Initialize .loki directory first
27600
27942
  mkdir -p .loki/logs
27601
27943
 
@@ -28231,7 +28573,9 @@ main() {
28231
28573
  # then advise the user how to open a PR. Both are no-ops when no agent branch
28232
28574
  # was set up (LOKI_BRANCH_PROTECTION=false) or nothing changed.
28233
28575
  commit_session_changes
28234
- create_session_pr
28576
+ # Trusted post-session step: it gets the GitHub token main() withheld from
28577
+ # agent sessions (its push may authenticate through the env token).
28578
+ _loki_with_github_tokens create_session_pr
28235
28579
  audit_agent_action "session_stop" "Session ended" "result=$result,iterations=$ITERATION_COUNT"
28236
28580
 
28237
28581
  # The first terminal summary is written before session changes are committed.
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.55.0"
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