loki-mode 9.55.0 → 9.57.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 (97) 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 +385 -22
  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/dev/architecture-reference.md +99 -0
  17. package/docs/dev/project-structure.md +134 -0
  18. package/docs/dev/release-checklist.md +157 -0
  19. package/docs/dev/research-foundation.md +29 -0
  20. package/docs/dev/tmp-cleanup.md +157 -0
  21. package/docs/environment-variables.md +1 -0
  22. package/docs/v10/BACKLOG.md +33 -0
  23. package/docs/v10/BOARD.md +97 -27
  24. package/docs/v10/DECISIONS.md +43 -0
  25. package/docs/v10/OPERATING-MODEL.md +47 -0
  26. package/docs/v10/PROGRESS.md +893 -0
  27. package/loki-ts/dist/loki.js +326 -325
  28. package/mcp/__init__.py +1 -1
  29. package/package.json +1 -1
  30. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  31. package/providers/claude.sh +1 -1
  32. package/providers/model_catalog.json +12 -5
  33. package/providers/models.sh +1 -1
  34. package/references/core-workflow.md +1 -1
  35. package/web-app/dist/assets/{AdminPage-CZrBkzRF.js → AdminPage-Oyx-w2Ch.js} +1 -1
  36. package/web-app/dist/assets/{Avatar-gQIp6FBI.js → Avatar-D60m65JB.js} +1 -1
  37. package/web-app/dist/assets/{Badge-D2_fBTFY.js → Badge-CqfZNaem.js} +1 -1
  38. package/web-app/dist/assets/{Button-D5qDd2ef.js → Button-BqjzE4fz.js} +1 -1
  39. package/web-app/dist/assets/CockpitPage-Czk7o_ou.js +47 -0
  40. package/web-app/dist/assets/ComparePage-BEfTrTb1.js +1 -0
  41. package/web-app/dist/assets/{ErrorBoundary-BRABI-BT.js → ErrorBoundary-BrLk5nkO.js} +1 -1
  42. package/web-app/dist/assets/EvidenceReceiptPanel-9vEpi5ir.js +1 -0
  43. package/web-app/dist/assets/{GitHubIssuesPanel-BC5GzKUN.js → GitHubIssuesPanel-D6izZx0w.js} +1 -1
  44. package/web-app/dist/assets/GitHubPRsPanel-BTkG2FgA.js +17 -0
  45. package/web-app/dist/assets/{HomePage-D_CDMoen.js → HomePage-nsr4kpN1.js} +2 -2
  46. package/web-app/dist/assets/{LoginPage-BBNA_N9t.js → LoginPage-BVblQrr9.js} +1 -1
  47. package/web-app/dist/assets/{MagicPage-CnFRZCMx.js → MagicPage-CmnSyLq7.js} +1 -1
  48. package/web-app/dist/assets/{MetricsPage-CPfLMRlh.js → MetricsPage-BVfwU81j.js} +1 -1
  49. package/web-app/dist/assets/{NotFoundPage-lA0XSGTz.js → NotFoundPage-AYQ6uqqI.js} +1 -1
  50. package/web-app/dist/assets/{ProjectPage-CNAPbr9z.js → ProjectPage-DMnbw9X9.js} +3 -3
  51. package/web-app/dist/assets/{ProjectsPage-CMtm8goN.js → ProjectsPage-CNVTAF2N.js} +1 -1
  52. package/web-app/dist/assets/{SettingsPage-BhIjVMYE.js → SettingsPage-D1_qasFD.js} +3 -3
  53. package/web-app/dist/assets/{ShowcasePage-BndtGeYo.js → ShowcasePage-2dBCjLXn.js} +1 -1
  54. package/web-app/dist/assets/{SystemSettingsPage-euXI-KXE.js → SystemSettingsPage-Djo6IE4u.js} +1 -1
  55. package/web-app/dist/assets/{TeamsPage-ClUKecun.js → TeamsPage-CY8p-yxD.js} +1 -1
  56. package/web-app/dist/assets/{TemplatesPage-BWHWVk0L.js → TemplatesPage-B6iFgH6t.js} +1 -1
  57. package/web-app/dist/assets/{TerminalOutput-Cv3N3Szf.js → TerminalOutput-B3yzZe-h.js} +1 -1
  58. package/web-app/dist/assets/{activity-dEhz-bXR.js → activity-CNfAjtRv.js} +1 -1
  59. package/web-app/dist/assets/{bell-B3XUMq1t.js → bell-CMETsHPH.js} +1 -1
  60. package/web-app/dist/assets/{bot-X-ssNlwR.js → bot-C9kXvk1i.js} +1 -1
  61. package/web-app/dist/assets/{check-C_9Nx7kb.js → check-BRK-zLB5.js} +1 -1
  62. package/web-app/dist/assets/{chevron-left-tvNswo3A.js → chevron-left-CxI5uD4g.js} +1 -1
  63. package/web-app/dist/assets/{circle-alert-CSB88nEs.js → circle-alert-CVFRPWpv.js} +1 -1
  64. package/web-app/dist/assets/{clock-C8p2e4NI.js → clock-CLYV8a_x.js} +1 -1
  65. package/web-app/dist/assets/{cloud-CKp2ZvhG.js → cloud-CiISiIOa.js} +1 -1
  66. package/web-app/dist/assets/{code-xml-dY70LCHD.js → code-xml-DHsgqwV-.js} +1 -1
  67. package/web-app/dist/assets/{database-DObn5fmn.js → database-DbvUqtz3.js} +1 -1
  68. package/web-app/dist/assets/{dollar-sign-DSu6n3K3.js → dollar-sign-Ct0lRPHw.js} +1 -1
  69. package/web-app/dist/assets/{file-code-corner-DpCnS73z.js → file-code-corner-D1sjFEh7.js} +1 -1
  70. package/web-app/dist/assets/{file-plus-D3IPKCf1.js → file-plus-DnmX18Fi.js} +1 -1
  71. package/web-app/dist/assets/{globe-C9QXkgH_.js → globe-bSpDNgoA.js} +1 -1
  72. package/web-app/dist/assets/{hammer-D_FtWe1y.js → hammer-Bj41Kvlq.js} +1 -1
  73. package/web-app/dist/assets/{index-CI54rara.css → index-BfQ0f3-S.css} +1 -1
  74. package/web-app/dist/assets/{index-Cz7q1bvC.js → index-CxajNJVu.js} +3 -3
  75. package/web-app/dist/assets/{layers-DsW7jnJX.js → layers-BNtBwYER.js} +1 -1
  76. package/web-app/dist/assets/{loader-circle-B3HspjKR.js → loader-circle-YY_VZofG.js} +1 -1
  77. package/web-app/dist/assets/{lock-DSI29pct.js → lock-rpjEPVoC.js} +1 -1
  78. package/web-app/dist/assets/{package-CLNxtA2x.js → package-desXU6XA.js} +1 -1
  79. package/web-app/dist/assets/{plus-DyI4WC4_.js → plus-7AoOU-I9.js} +1 -1
  80. package/web-app/dist/assets/{refresh-cw-D2dv0xA0.js → refresh-cw-oNj-Xn4B.js} +1 -1
  81. package/web-app/dist/assets/{rotate-ccw-CBvihA-a.js → rotate-ccw-DeiFjfVO.js} +1 -1
  82. package/web-app/dist/assets/{scroll-text-DvoOvAl_.js → scroll-text-Q9AzWiNV.js} +1 -1
  83. package/web-app/dist/assets/{server-DenWvSUt.js → server-DKgVwfek.js} +1 -1
  84. package/web-app/dist/assets/{shield-alert-40cX_FvG.js → shield-alert-G6x-bS-I.js} +1 -1
  85. package/web-app/dist/assets/{thumbs-up-BrjZ6QuV.js → thumbs-up-Qhk0yvw0.js} +1 -1
  86. package/web-app/dist/assets/{trash-2-C6evwkEH.js → trash-2-D23gvznl.js} +1 -1
  87. package/web-app/dist/assets/{trending-down-CfbZis3O.js → trending-down-CSQClPrr.js} +1 -1
  88. package/web-app/dist/assets/{trending-up-DWuEK3WC.js → trending-up-sjMSd1wc.js} +1 -1
  89. package/web-app/dist/assets/{upload-B9DFBcPG.js → upload-Dchovhld.js} +1 -1
  90. package/web-app/dist/assets/{usePolling-BryVC-T1.js → usePolling-DwLceXwF.js} +1 -1
  91. package/web-app/dist/assets/{user-DMRXwZiw.js → user-BqZM8CmD.js} +1 -1
  92. package/web-app/dist/index.html +2 -2
  93. package/web-app/server.py +52 -17
  94. package/web-app/dist/assets/CockpitPage-DVMk2RUK.js +0 -47
  95. package/web-app/dist/assets/ComparePage-NMLyE1xL.js +0 -1
  96. package/web-app/dist/assets/EvidenceReceiptPanel-D2LNKqUx.js +0 -1
  97. 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.
@@ -9159,10 +9218,27 @@ setup_agent_branch() {
9159
9218
 
9160
9219
  # Record the user's own untracked and gitignored files so
9161
9220
  # commit_session_changes and the receipt leave them alone.
9162
- local snap_ok=1 leftover=""
9221
+ local snap_ok=1 leftover="" gittool=""
9163
9222
  if ! _loki_snapshot_or_fail_closed; then
9164
9223
  snap_ok=0
9165
- rm -f .loki/state/preexisting-untracked.z .loki/state/preexisting-untracked.sha.z 2>/dev/null
9224
+ rm -f .loki/state/preexisting-untracked.sha.z 2>/dev/null
9225
+ # BACKLOG 88: the receipt (workspace_diff._preexisting_untracked) reads
9226
+ # preexisting-untracked.z on its own, independent of
9227
+ # _LOKI_SNAPSHOT_THIS_RUN below. Leaving it absent after a failed
9228
+ # snapshot (old code: `rm -f` here too) makes the receipt treat EVERY
9229
+ # currently-untracked file as this run's own work, even though nothing
9230
+ # is actually committed -- a pre-existing file the user never touched
9231
+ # gets named as the run's. `ls-files --others --exclude-standard` needs
9232
+ # no --no-renames or --ignored=matching, so it still works on the git
9233
+ # that just failed the richer `status` call above, giving the receipt a
9234
+ # names-only fallback list. Ignored files are not covered by this
9235
+ # fallback (old-git territory), so it is partial, not a full snapshot;
9236
+ # the commit path is untouched (snap_ok stays 0, _LOKI_SNAPSHOT_THIS_RUN
9237
+ # is never set below), so this still fails closed.
9238
+ gittool="$(_loki_snapshot_git_tool)" && \
9239
+ "$gittool" ls-files --others --exclude-standard -z > .loki/state/preexisting-untracked.z.tmp 2>/dev/null && \
9240
+ mv .loki/state/preexisting-untracked.z.tmp .loki/state/preexisting-untracked.z \
9241
+ || rm -f .loki/state/preexisting-untracked.z .loki/state/preexisting-untracked.z.tmp 2>/dev/null
9166
9242
  log_warn "Could not record pre-existing untracked files; this session will commit nothing (review and commit manually)"
9167
9243
  fi
9168
9244
 
@@ -9180,6 +9256,12 @@ setup_agent_branch() {
9180
9256
  log_warn "Left uncommitted from an earlier unfinished session, now treated as your files: $leftover"
9181
9257
  fi
9182
9258
  rm -f .loki/state/session-created.z 2>/dev/null
9259
+ # BACKLOG 130(c): seal the now-empty session-created.z record so this
9260
+ # fresh mint's OWN in-process memory of "absent" is what every later
9261
+ # record call and commit_session_changes verify against for the rest of
9262
+ # this run -- not the file's on-disk content, which an agent process could
9263
+ # otherwise repopulate with a planted path before this run ever trusts it.
9264
+ _loki_session_created_seal # BACKLOG-130C-MINT-SEAL
9183
9265
  # BACKLOG 68 rework: a fresh mint starts with no tracked-since anchor and no
9184
9266
  # in-flight marker (never seed the anchor from session-start-sha -- an
9185
9267
  # anchor==start would let a later resume's "anchor..HEAD" diff include this
@@ -9236,6 +9318,40 @@ _loki_snapshot_or_fail_closed() {
9236
9318
  # ~9165) so that path is never reported as tampering.
9237
9319
  _LOKI_SNAPSHOT_SEAL=""
9238
9320
 
9321
+ # BACKLOG 130(c): .loki/state/session-created.z is NOT one of the two files
9322
+ # _LOKI_SNAPSHOT_SEAL above covers. It records the paths THIS session created
9323
+ # (unioned after every provider turn by _loki_record_session_created), and
9324
+ # _loki_snapshot_preexisting's resume union (line ~9683) trusts its content
9325
+ # directly: any path it lists is EXCLUDED from the new preexisting-untracked.z
9326
+ # snapshot, i.e. treated as "the session's own, never the user's". An agent
9327
+ # that plants a user's file path into session-created.z therefore gets that
9328
+ # exact path permanently exempted from pre-existing-file protection on the
9329
+ # next resume -- the file is swept into `git add -A` and the session commit as
9330
+ # if the session had created it. A separate seal, sibling to
9331
+ # _LOKI_SNAPSHOT_SEAL, closes this WITHIN one process's lifetime: sealed right
9332
+ # after every legitimate write this process makes (a successful record, the
9333
+ # fresh-mint delete, and this process's first look at the file on resume),
9334
+ # verified before any LATER point in the SAME process trusts the content,
9335
+ # fail-closed (delete the file, clear the seal) on a mismatch. Kept SEPARATE
9336
+ # from _LOKI_SNAPSHOT_SEAL rather than folded into one combined digest: this
9337
+ # file legitimately changes every provider turn, so a shared digest would
9338
+ # falsely flag tampering on every normal turn, and the fresh-mint `rm` (line
9339
+ # ~9241) runs after preexisting-untracked.z is sealed, which would falsely
9340
+ # flag every fresh mint too.
9341
+ # ACCEPTED GAP (disclosure, not prevention, same bar as BACKLOG 70's own): a
9342
+ # seal lives only in this process's memory, so it cannot verify content a
9343
+ # PRIOR process wrote before this one starts (setup_agent_branch's resume path
9344
+ # seals its own first read of the file rather than verifying it, since no
9345
+ # prior seal survives to check against) -- tampering already on disk before
9346
+ # this process begins is trusted at that one read, same as
9347
+ # _LOKI_SNAPSHOT_SEAL's own resume-time trust of preexisting-untracked.z's
9348
+ # prior content. What this DOES close: an agent process co-located with THIS
9349
+ # run can no longer plant a path into session-created.z between this run's own
9350
+ # writes and its own later reads (the resume union's exclude use, the next
9351
+ # record's merge, or the commit-time gate) without it being caught before that
9352
+ # content is trusted.
9353
+ _LOKI_SESSION_CREATED_SEAL=""
9354
+
9239
9355
  # BACKLOG 129 (follow-up to BACKLOG 70/BACKLOG 70-awk): the DIGEST computation
9240
9356
  # above is hardened (fixed-root-path-first, no awk), but until this function
9241
9357
  # was added the snapshot WRITER (_loki_untracked_merge) and READER
@@ -9421,11 +9537,15 @@ _loki_snapshot_hash_tool() {
9421
9537
  # which a user-writable directory ahead of the system dirs (e.g. Homebrew's
9422
9538
  # prefix on macOS) can shadow -- not fully closed, only narrowed to hosts
9423
9539
  # 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.
9540
+ # the extremely rare host with neither tool anywhere, is resolved via
9541
+ # _loki_snapshot_py_tool and run with -I -S (BACKLOG 131(b) -- see the comment
9542
+ # at that fallback for why -S, not just -I, is required to close the .pth
9543
+ # class), so it carries the same fixed-root-path-first resolution and
9544
+ # site-packages exclusion as the other hardened call sites in this file. It
9545
+ # still inherits _loki_snapshot_py_tool's own accepted gaps (documented at
9546
+ # that function): replacing the interpreter AT ONE OF ITS FIXED PATHS requires
9547
+ # root, and its own PATH-walk fallback still trusts whatever sits first on an
9548
+ # absolute PATH entry on a host lacking those fixed paths.
9429
9549
  _loki_snapshot_digest() {
9430
9550
  local tool
9431
9551
  tool="$(_loki_snapshot_hash_tool)" || tool=""
@@ -9464,10 +9584,17 @@ _loki_snapshot_digest() {
9464
9584
  return 0
9465
9585
  fi
9466
9586
  # 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
9587
+ # (extremely rare on macOS/Linux). Resolved via _loki_snapshot_py_tool
9588
+ # (fixed root-owned path first) and run with -I -S, the same BACKLOG 129
9589
+ # pattern used at _loki_untracked_merge / _loki_covered_paths: -I alone is
9590
+ # NOT enough -- a reviewer reproduced, on a sibling function in this same
9591
+ # tamper-detection system, that a .pth file planted in Homebrew python3's
9592
+ # own (user-writable) site-packages directory still fires under `python3
9593
+ # -I` alone, since -I implies -s (skip user site-packages) but does not
9594
+ # skip the resolved interpreter's OWN site-packages. Only -S (skip ALL
9595
+ # site-packages, including the interpreter's own) closes that. A resolve
9596
+ # failure degrades this fallback to printing nothing, same as any other
9597
+ # failure path here (caller treats empty as "could not seal"). Additional
9471
9598
  # accepted gap specific to this fallback (not present on the tool-based
9472
9599
  # path above, which distinguishes "-" from "?"): OSError covers BOTH
9473
9600
  # "file does not exist" and "file exists but is unreadable" here, so an
@@ -9478,7 +9605,9 @@ _loki_snapshot_digest() {
9478
9605
  # for present-but-unreadable) is straightforward but left undone here: this
9479
9606
  # fallback only runs on a host with neither sha256sum nor shasum anywhere,
9480
9607
  # already the rare case this whole function treats as a residual gap.
9481
- python3 -I -c 'import sys, hashlib, os
9608
+ local pytool=""
9609
+ pytool="$(_loki_snapshot_py_tool)" || return 1
9610
+ "$pytool" -I -S -c 'import sys, hashlib
9482
9611
  sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9483
9612
  out = []
9484
9613
  for path in sys.argv[1:]:
@@ -9491,6 +9620,155 @@ print(" ".join(out))' \
9491
9620
  ".loki/state/preexisting-untracked.z" ".loki/state/preexisting-untracked.sha.z" 2>/dev/null
9492
9621
  }
9493
9622
 
9623
+ # _loki_session_created_digest: sha256 of session-created.z alone ("-" for
9624
+ # absent, "?" for present-but-unreadable/unhashable, same sentinel contract as
9625
+ # _loki_snapshot_digest above). Deliberately a thin sibling, not a parameterized
9626
+ # reuse of _loki_snapshot_digest: that function's exact text (the `for path in
9627
+ # ...` line and the python fallback's trailing argv line) is matched by
9628
+ # tests/test-branch-lifecycle.sh's RED mutation harness, so leaving it
9629
+ # untouched keeps that harness's anchors valid. Resolved via the SAME hardened
9630
+ # tool resolvers (_loki_snapshot_hash_tool, _loki_snapshot_py_tool) as the
9631
+ # sibling above -- no fresh/unhardened python3 or shasum call site.
9632
+ _loki_session_created_digest() {
9633
+ local tool
9634
+ tool="$(_loki_snapshot_hash_tool)" || tool=""
9635
+ local path=".loki/state/session-created.z"
9636
+ # A FIFO or ANY symlink at $path (dangling or not -- [ -L ] rejects both,
9637
+ # deliberately broader than just the dangling case: the only way this
9638
+ # file is ever legitimately written is _loki_untracked_merge's own
9639
+ # write-to-.tmp-then-`mv -f`, which always leaves a plain regular file at
9640
+ # $path, never a symlink, so a symlink here is never this session's own
9641
+ # legitimate output) is neither absent ([ -f ] is false for both) nor a
9642
+ # hashable regular file -- falling through to the absent-file branch below
9643
+ # would print the same "-" sentinel every fresh mint already seals as its
9644
+ # normal starting state, silently trusting an agent-planted FIFO (whose
9645
+ # blocked writer feeds content into the merge's later open() call) or a
9646
+ # dangling symlink (whose target read fails and is treated as "-" too).
9647
+ # Caught here, before either digest branch, with a sentinel ("?") that can
9648
+ # never equal "-", a real hex digest, or empty.
9649
+ if [ -L "$path" ] || { [ -e "$path" ] && [ ! -f "$path" ]; }; then printf '%s\n' "?"; return 0; fi # BACKLOG-130C-NONREG-CHECK
9650
+ if [ -n "$tool" ]; then
9651
+ local h=""
9652
+ if [ -f "$path" ]; then
9653
+ case "${tool##*/}" in
9654
+ shasum) h="$("$tool" -a 256 -- "$path" 2>/dev/null)" || h="" ;;
9655
+ *) h="$("$tool" -- "$path" 2>/dev/null)" || h="" ;;
9656
+ esac
9657
+ h="${h%% *}"
9658
+ if [ "${#h}" -eq 64 ]; then
9659
+ case "$h" in
9660
+ *[!0123456789abcdef]*) h="?" ;;
9661
+ esac
9662
+ else
9663
+ h="?"
9664
+ fi
9665
+ printf '%s\n' "$h"
9666
+ else
9667
+ printf '%s\n' "-"
9668
+ fi
9669
+ return 0
9670
+ fi
9671
+ # Fallback: neither sha256sum nor shasum found anywhere (same rare-host
9672
+ # case _loki_snapshot_digest's own fallback documents). Resolved via
9673
+ # _loki_snapshot_py_tool, run with -I -S (see that function's comment for
9674
+ # why -S, not just -I, is required to close the .pth vector).
9675
+ local pytool=""
9676
+ pytool="$(_loki_snapshot_py_tool)" || return 1
9677
+ "$pytool" -I -S -c 'import sys, hashlib
9678
+ sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9679
+ try:
9680
+ with open(sys.argv[1], "rb") as fh:
9681
+ print(hashlib.sha256(fh.read()).hexdigest())
9682
+ except OSError:
9683
+ print("-")' \
9684
+ "$path" 2>/dev/null
9685
+ }
9686
+
9687
+ # _loki_session_created_seal: record this process's own memory of the current
9688
+ # session-created.z content, right after every point that legitimately changes
9689
+ # it (a successful union in _loki_record_session_created, the fresh-mint
9690
+ # delete, a resume, and the post-commit/no-op delete in
9691
+ # commit_session_changes). Same fail-closed shape as _loki_snapshot_seal: an
9692
+ # empty or "?" digest at seal time is itself suspicious (a legitimate write or
9693
+ # delete should never immediately be unreadable), so it is rejected rather
9694
+ # than sealed, which would let seal and a later verify silently agree on the
9695
+ # sentinel and disarm the guard for the rest of the session.
9696
+ _loki_session_created_seal() {
9697
+ local d
9698
+ d="$(_loki_session_created_digest)"
9699
+ case "$d" in
9700
+ ""|*'?'*)
9701
+ # Fail closed, not just "untrusted": an empty seal makes
9702
+ # _loki_session_created_verify's `[ -n "$_LOKI_SESSION_CREATED_SEAL" ]
9703
+ # || return 0` skip every later check for the rest of this
9704
+ # process's life. Leaving the file on disk here would fail OPEN --
9705
+ # unreadable-but-present content would then be silently trusted by
9706
+ # every later read site with no seal ever able to catch it. Delete
9707
+ # it instead, matching _loki_session_created_verify's own
9708
+ # fail-closed action on a real mismatch -- and, like that function's
9709
+ # own re-seal after its delete, set the seal to the digest's real
9710
+ # "absent" sentinel ("-"), not empty: an EMPTY seal here re-arms
9711
+ # nothing (the guard above short-circuits as "never sealed" on
9712
+ # every later check for the rest of this process), so an agent that
9713
+ # recreates the file with a planted path in this window would get
9714
+ # it silently merged in as trusted on the next successful record.
9715
+ # "-" is a real, non-empty seal value that the next verify's live
9716
+ # digest of a freshly-recreated file cannot match, keeping the
9717
+ # guard armed against exactly that recreation.
9718
+ rm -f .loki/state/session-created.z 2>/dev/null
9719
+ _LOKI_SESSION_CREATED_SEAL="-"
9720
+ 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)"
9721
+ audit_log "SESSION_CREATED_SEAL_FAILED" "_loki_session_created_digest returned empty or an unreadable-file sentinel at seal time"
9722
+ ;;
9723
+ *)
9724
+ _LOKI_SESSION_CREATED_SEAL="$d"
9725
+ ;;
9726
+ esac
9727
+ }
9728
+
9729
+ # _loki_session_created_verify: compare the live session-created.z digest
9730
+ # against the sealed one. No seal recorded (never sealed this process, or
9731
+ # deliberately cleared) is not tampering: returns success, does nothing --
9732
+ # EVERY caller that trusts session-created.z's content must therefore have
9733
+ # sealed it first (mint, resume, and after each successful record all do). A
9734
+ # seal that no longer matches IS tampering: logs and discloses it (same
9735
+ # DISCLOSURE bar as BACKLOG 70, not prevention against a same-UID process), and
9736
+ # fails closed by deleting the untrusted file outright -- its entries then
9737
+ # become "the user's, not recorded as this session's", the safe direction,
9738
+ # since a false claim of user ownership only leaves a file uncommitted on disk,
9739
+ # never deletes it, while a false claim of session ownership can get a user's
9740
+ # file swept into a commit and removed from disk on a later checkout of the
9741
+ # base. Called BEFORE any later point in the SAME process treats the file's
9742
+ # content as trustworthy (verify-before-trust, not verify-after-use):
9743
+ # _loki_record_session_created verifies at its very top, before its own
9744
+ # self-referential merge (base="$out"), and unconditionally -- not gated on
9745
+ # _LOKI_SNAPSHOT_THIS_RUN, since an interrupt/cleanup call can still fire after
9746
+ # that flag was cleared mid-session; commit_session_changes verifies at its
9747
+ # very top, before every one of its several early returns (the untrack
9748
+ # failure, the git 2.25 unstage failure, a secret-scan abort, a failed `git
9749
+ # commit`) that would otherwise skip its own rm -f cleanup and leave a
9750
+ # tampered file on disk for the next resume. _loki_resume_snapshot does NOT
9751
+ # call this function: it is this process's FIRST read of a file the PREVIOUS
9752
+ # (dead) process wrote, so there is no seal from that process to verify
9753
+ # against here; it instead calls _loki_session_created_seal directly to
9754
+ # establish THIS process's own baseline before anything (including
9755
+ # _loki_snapshot_preexisting's resume-union exclude use, right after) reads
9756
+ # the file. See the ACCEPTED GAP note at _LOKI_SESSION_CREATED_SEAL above.
9757
+ _loki_session_created_verify() {
9758
+ local live="" path=".loki/state/session-created.z"
9759
+ [ -n "$_LOKI_SESSION_CREATED_SEAL" ] || return 0
9760
+ live="$(_loki_session_created_digest)"
9761
+ if [ "$live" = "$_LOKI_SESSION_CREATED_SEAL" ]; then
9762
+ return 0
9763
+ fi
9764
+ 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)"
9765
+ audit_log "SESSION_CREATED_TAMPERED" "session-created.z changed after being sealed: live=$live,sealed=$_LOKI_SESSION_CREATED_SEAL"
9766
+ rm -f "$path" 2>/dev/null
9767
+ _LOKI_SESSION_CREATED_SEAL=""
9768
+ _loki_session_created_seal
9769
+ return 1
9770
+ }
9771
+
9494
9772
  # _loki_snapshot_seal: record this process's own memory of what it just wrote.
9495
9773
  # Called only right after a successful _loki_snapshot_preexisting. Minor
9496
9774
  # reviewer note: a legitimate snapshot write should never hit an unreadable-
@@ -9556,10 +9834,13 @@ _loki_snapshot_verify() {
9556
9834
  if [ "$live" = "$_LOKI_SNAPSHOT_SEAL" ]; then
9557
9835
  return 0
9558
9836
  fi
9837
+ 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)"
9838
+ # Record both digests (before clearing the seal below) so an operator
9839
+ # reading the audit log can tell a genuine content tamper apart from a
9840
+ # transient hash-tool failure that just produced a different digest.
9841
+ audit_log "SNAPSHOT_TAMPERED" "preexisting-untracked.z or .sha.z changed after being sealed: live=$live,sealed=$_LOKI_SNAPSHOT_SEAL"
9559
9842
  _LOKI_SNAPSHOT_SEAL=""
9560
9843
  _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
9844
  mkdir -p .loki/state 2>/dev/null
9564
9845
  : > .loki/state/preexisting-untracked.failed 2>/dev/null
9565
9846
  return 1
@@ -9844,6 +10125,36 @@ _loki_advance_tracked_since_anchor() {
9844
10125
  # disk, not committed).
9845
10126
  _loki_record_session_created() {
9846
10127
  local snap=".loki/state/preexisting-untracked.z" out=".loki/state/session-created.z"
10128
+ # BACKLOG 130(c): verify session-created.z BEFORE this run's THIS_RUN gate
10129
+ # or its own merge trust the file -- verify-before-trust, not verify-after-
10130
+ # use. Runs unconditionally (not gated on THIS_RUN=1): an interrupt/cleanup
10131
+ # call can still fire after THIS_RUN was cleared mid-session (e.g. by
10132
+ # _loki_snapshot_verify below on an earlier turn), and that call must not
10133
+ # skip this check just because the gate below is about to return early. On
10134
+ # a mismatch the file is already deleted by the time this returns (fail
10135
+ # closed inside _loki_session_created_verify itself, not via this return
10136
+ # value alone -- every call site of this function ignores its return with
10137
+ # `|| true`, same as _loki_snapshot_verify above); the merge below then
10138
+ # recomputes cleanly from a clean slate (base="$out" is now absent), so a
10139
+ # legitimate record right after a caught tamper still records this turn's
10140
+ # real files instead of hard-failing the whole call.
10141
+ # ACCEPTED GAP (verify-to-use window, disclosed here since ordering is the
10142
+ # whole point of this fix): this verify call and the merge's own read of
10143
+ # "$out" as `base` (inside _loki_untracked_merge, below) are not atomic --
10144
+ # _loki_advance_tracked_since_anchor, a git rev-parse, and a whole-repo
10145
+ # `git status` all run in between. A same-UID write landing in that window
10146
+ # is merged, then sealed as trusted by the seal call after a successful
10147
+ # merge. This is the SAME shape of gap _LOKI_SNAPSHOT_SEAL's own
10148
+ # verify/merge pair already accepts for preexisting-untracked.z (see
10149
+ # _loki_untracked_merge's own BACKLOG 129 comment). Closing it would mean
10150
+ # re-verifying again immediately before _loki_untracked_merge's read, inside
10151
+ # that function -- deliberately not done here: _loki_untracked_merge's exact
10152
+ # text is what tests/test-branch-lifecycle.sh's RED mutation harness matches
10153
+ # by name-anchor, and threading a second, narrower verify through its
10154
+ # shared codepath (also called with mode=exact for the OTHER snapshot) risks
10155
+ # that anchor for a gap this narrow. Disclosure, not full prevention, is the
10156
+ # same bar BACKLOG 70 itself accepts.
10157
+ _loki_session_created_verify || true
9847
10158
  [ "${_LOKI_SNAPSHOT_THIS_RUN:-0}" = 1 ] || return 0
9848
10159
  # BACKLOG 70: detect tampering with the sealed snapshot as early as
9849
10160
  # possible (this runs after every provider turn, not just at commit time).
@@ -9857,6 +10168,11 @@ _loki_record_session_created() {
9857
10168
  if [ -f "$snap" ] && _loki_untracked_status "$out.status" \
9858
10169
  && _loki_untracked_merge "$out.status" "$out" "$snap" cover "$out"; then
9859
10170
  rm -f "$out.status"
10171
+ # BACKLOG 130(c): seal this process's own memory of the record it just
10172
+ # wrote, so the NEXT call to this function (or commit_session_changes)
10173
+ # verifies against what THIS process actually wrote, not whatever an
10174
+ # agent process may write to the file in between.
10175
+ _loki_session_created_seal
9860
10176
  return 0
9861
10177
  fi
9862
10178
  rm -f "$out.status" "$out.tmp" 2>/dev/null
@@ -9868,6 +10184,19 @@ _loki_record_session_created() {
9868
10184
  # from an unfinished previous session (this session's commit includes them).
9869
10185
  _loki_resume_snapshot() {
9870
10186
  local carried=""
10187
+ # BACKLOG 130(c): seal THIS process's own first look at session-created.z
10188
+ # before anything below reads or trusts it (verify-before-trust). This is
10189
+ # a resuming process picking up a file the PREVIOUS (now-dead) process
10190
+ # wrote; there is no seal from that process to verify against here (a
10191
+ # seal never survives past the process that set it) -- sealing now is what
10192
+ # lets THIS process detect an agent tampering with the file for the rest
10193
+ # of ITS OWN lifetime (every subsequent record call, and the commit-time
10194
+ # check), the same disclosure bar BACKLOG 70 already accepts for the
10195
+ # sibling snapshot: tampering already on disk before this process starts
10196
+ # is an accepted gap, never silently trusted forever after. Unconditional
10197
+ # (not gated on the union below succeeding): the union does not modify
10198
+ # session-created.z, so this process's view of it is stable either way.
10199
+ _loki_session_created_seal
9871
10200
  if ! _loki_snapshot_or_fail_closed union; then
9872
10201
  log_warn "Could not add your current untracked files to the pre-existing list; this session will commit nothing (review and commit manually)"
9873
10202
  return 0
@@ -10005,7 +10334,9 @@ _loki_untrack_agent_committed_user_files() {
10005
10334
  fi
10006
10335
  rm -f "$rec.added"
10007
10336
  if [ ! -s "$rec.new" ]; then
10008
- rm -f "$rec.new"
10337
+ # BACKLOG 102: zero hits this session -- clear a stale record left by
10338
+ # an earlier session, or it persists forever with nothing to read it.
10339
+ rm -f "$rec.new" "$rec"
10009
10340
  return 0
10010
10341
  fi
10011
10342
  mv -f "$rec.new" "$rec"
@@ -10028,6 +10359,18 @@ commit_session_changes() {
10028
10359
  command -v git >/dev/null 2>&1 || return 0
10029
10360
  git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
10030
10361
 
10362
+ # BACKLOG 130(c): verify session-created.z BEFORE any of this function's
10363
+ # several early returns below (the untrack failure, the git 2.25 unstage
10364
+ # failure, the secret-scan abort, a failed `git commit`) -- every one of
10365
+ # them skips the two rm -f .loki/state/session-created.z cleanup sites
10366
+ # further down, so a tampered file left on disk by any of those paths
10367
+ # would otherwise survive untouched into the next session's resume union.
10368
+ # Placed at the very top, before even the branch-protection/PARALLEL_MODE/
10369
+ # branch-name checks below, so no early return in this function can skip
10370
+ # it. On a mismatch the file is deleted here already; nothing downstream
10371
+ # needs its own check.
10372
+ _loki_session_created_verify || true # BACKLOG-130C-COMMIT-TOP-VERIFY
10373
+
10031
10374
  # Only act when a session feature branch was set up. This preserves the
10032
10375
  # LOCK A1 opt-out contract (LOKI_BRANCH_PROTECTION=false -> no agent-branch.txt
10033
10376
  # -> we never commit on the user's own branch), and also no-ops the detached
@@ -13283,14 +13626,26 @@ TREOF
13283
13626
  # summary the old best-effort parse of the tail still applies (null when it
13284
13627
  # finds nothing). Either way a recorded count above zero is a failure: the
13285
13628
  # council evidence gate and the Bun gate both read failed_count > 0 as one.
13629
+ # BACKLOG 111: pytest's summary line also reports a distinct "error" outcome
13630
+ # for a fixture/collection/setup error ("1 passed, 1 error in 0.5s"), which
13631
+ # is neither "passed" nor "failed" text, so the [0-9]+ failed match alone
13632
+ # found nothing and printed n=0 even though seen=1 -- a real error gated
13633
+ # PASS. pytest pluralizes it ("2 errors"), so match errors? and ADD it to
13634
+ # the failed count on that line ("2 failed, 3 errors" is 5 distinct broken
13635
+ # tests, not 3 -- failed and error are independent outcomes within one
13636
+ # summary line, unlike the separate cross-line max below, which exists to
13637
+ # stop a later green summary from hiding an earlier failed run).
13286
13638
  # LC_ALL=C: bytes, so any runner output parses; [ \t] not [[:space:]] for
13287
13639
  # old mawk.
13288
13640
  local _tr_passed_n _tr_failed_n _tr_summary_red=false
13289
13641
  _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
13642
  function upd(n) { if (n > f) f = n; seen = 1 }
13291
13643
  /^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) }
13644
+ /^=+ .*[0-9]+ (passed|failed|errors?)/ || /^[0-9]+ (passed|failed|errors?).* in [0-9.]+s/ {
13645
+ n = 0
13646
+ if (match($0, /[0-9]+ failed/)) n = substr($0, RSTART, RLENGTH) + 0
13647
+ if (match($0, /[0-9]+ errors?/)) n += substr($0, RSTART, RLENGTH) + 0
13648
+ upd(n) }
13294
13649
  /^[^ A-Za-z0-9]+ fail [0-9]+[ \t]*$/ { if (prev ~ /^[^ A-Za-z0-9]+ pass [0-9]+[ \t]*$/) upd($3 + 0) }
13295
13650
  /^[ \t]*[0-9]+ passing \(/ { upd(0) }
13296
13651
  /^[ \t]*[0-9]+ failing[ \t]*$/ { upd($1 + 0) }
@@ -13643,8 +13998,8 @@ if not isinstance(d, dict):
13643
13998
  verdict = d.get('verdict', 'unknown')
13644
13999
  diff = d.get('diff', {}) if isinstance(d.get('diff'), dict) else {}
13645
14000
  tests = d.get('tests', {}) if isinstance(d.get('tests'), dict) else {}
13646
- diff_ok = diff.get('ok')
13647
- tests_ok = tests.get('ok')
14001
+ diff_ok = 'inconclusive' if diff.get('inconclusive') else diff.get('ok')
14002
+ tests_ok = 'inconclusive' if tests.get('inconclusive') else tests.get('ok')
13648
14003
  runner = tests.get('runner', 'none')
13649
14004
  parts = ['verdict=%s' % verdict]
13650
14005
  parts.append('diff_ok=%s' % diff_ok)
@@ -27428,6 +27783,8 @@ except (json.JSONDecodeError, OSError): pass
27428
27783
  main() {
27429
27784
  _loki_install_signal_traps
27430
27785
  SESSION_START_EPOCH=$(date +%s)
27786
+ # Before anything can spawn a provider (capability probes included).
27787
+ _loki_withhold_github_tokens
27431
27788
 
27432
27789
  # First-run disclosure (shown once, before any work; best-effort).
27433
27790
  if type loki_show_disclosure_once &>/dev/null; then
@@ -27596,6 +27953,10 @@ main() {
27596
27953
 
27597
27954
  # Handle background mode
27598
27955
  if [ "$BACKGROUND_MODE" = "true" ]; then
27956
+ # The relaunched runner withholds the tokens itself; it needs them in
27957
+ # its environment to do so. This process exits right after launching.
27958
+ local _bg_tok
27959
+ for _bg_tok in $_LOKI_WITHHELD_TOKENS; do export "${_bg_tok?}"; done
27599
27960
  # Initialize .loki directory first
27600
27961
  mkdir -p .loki/logs
27601
27962
 
@@ -28231,7 +28592,9 @@ main() {
28231
28592
  # then advise the user how to open a PR. Both are no-ops when no agent branch
28232
28593
  # was set up (LOKI_BRANCH_PROTECTION=false) or nothing changed.
28233
28594
  commit_session_changes
28234
- create_session_pr
28595
+ # Trusted post-session step: it gets the GitHub token main() withheld from
28596
+ # agent sessions (its push may authenticate through the env token).
28597
+ _loki_with_github_tokens create_session_pr
28235
28598
  audit_agent_action "session_stop" "Session ended" "result=$result,iterations=$ITERATION_COUNT"
28236
28599
 
28237
28600
  # 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.57.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