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
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,18 @@ 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
9248
+ # BACKLOG 68 rework: a fresh mint starts with no tracked-since anchor and no
9249
+ # in-flight marker (never seed the anchor from session-start-sha -- an
9250
+ # anchor==start would let a later resume's "anchor..HEAD" diff include this
9251
+ # session's OWN commits, pruning them from the next session's union as if
9252
+ # the user had tracked them between sessions).
9253
+ rm -f .loki/state/tracked-since.sha .loki/state/turn-in-flight 2>/dev/null
9183
9254
  if [ "$snap_ok" = 1 ]; then _LOKI_SNAPSHOT_THIS_RUN=1; fi
9184
9255
 
9185
9256
  # Every mint (a refused resume included) records its own base and the
@@ -9213,6 +9284,551 @@ _loki_snapshot_or_fail_closed() {
9213
9284
  return 1
9214
9285
  }
9215
9286
 
9287
+ # BACKLOG 70: preexisting-untracked.z and its .sha.z sibling live in
9288
+ # .loki/state, which the agent process (and anything it spawns) can write. A
9289
+ # co-located adversarial agent can delete .sha.z to blind
9290
+ # workspace_diff._preexisting_untracked's mismatch check (silently suppressing
9291
+ # every preexisting_modified disclosure), or append/edit .z entries to hide its
9292
+ # own new files from the receipt or falsely disown them. Full prevention against
9293
+ # a same-UID process is not achievable (it can read and rewrite anything this
9294
+ # process can); the realistic bar, and the one BACKLOG 70 asks for, is
9295
+ # DISCLOSURE: this process is the only one that can remember what it itself
9296
+ # just wrote, so it seals a sha256 of both files in its own memory right after
9297
+ # writing them, and re-derives + compares that digest at every later read site.
9298
+ # A mismatch (or the sealed file now missing) is reported as tampering and
9299
+ # treated as a failed snapshot (fail closed: no session commit), never patched
9300
+ # over silently. Cleared whenever the files are deliberately removed (line
9301
+ # ~9165) so that path is never reported as tampering.
9302
+ _LOKI_SNAPSHOT_SEAL=""
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
+
9338
+ # BACKLOG 129 (follow-up to BACKLOG 70/BACKLOG 70-awk): the DIGEST computation
9339
+ # above is hardened (fixed-root-path-first, no awk), but until this function
9340
+ # was added the snapshot WRITER (_loki_untracked_merge) and READER
9341
+ # (_loki_covered_paths), plus the hash-snapshot helper that produces .sha.z,
9342
+ # still ran bare `python3 -E`. -E only blocks PYTHON* env vars; it does NOT
9343
+ # stop user (or Homebrew) site-packages from loading, and Python execs every
9344
+ # line starting "import " in every .pth file found there on every startup
9345
+ # (confirmed empirically: a .pth planted in user site-packages fired and
9346
+ # silently rewrote the writer's declared output file after the legitimate
9347
+ # write completed). A same-UID agent that plants such a .pth can forge the
9348
+ # CONTENT of the snapshot BEFORE the (correctly hardened) seal ever computes a
9349
+ # digest over it -- the seal then faithfully approves the forgery. Resolved
9350
+ # the same way as _loki_snapshot_hash_tool: fixed root-owned candidate paths
9351
+ # first (a materially higher bar than a user-writable site-packages
9352
+ # directory), each PROBED with a real run (not just `-x`, which a Command Line
9353
+ # Tools-less Mac's /usr/bin shims would still pass while failing to execute
9354
+ # and possibly popping an install dialog), then a PATH walk restricted to
9355
+ # absolute entries as a last resort. -I -S (isolated mode, and -S also skips
9356
+ # `import site` entirely) is used at every call site below: -I alone still
9357
+ # loads site-packages (verified: `python3 -I` still lists Homebrew's
9358
+ # site-packages on this host), so -S is required to close the .pth vector,
9359
+ # not just narrow it. Accepted gap, same shape as _loki_snapshot_hash_tool's:
9360
+ # replacing the interpreter binary AT ONE OF THE FIXED PATHS this resolves to
9361
+ # first requires root; a host with none of the fixed candidates falls through
9362
+ # to the PATH-walk, which still trusts whatever sits first on an absolute PATH
9363
+ # entry under that name. A further, host-specific gap: on macOS these fixed
9364
+ # paths are typically Command Line Tools / Xcode shims that dispatch into
9365
+ # /Applications/Xcode.app -- root-owned on a normal install, but the fixed
9366
+ # path is only as trustworthy as that dispatch target on any host where it is
9367
+ # not.
9368
+ _loki_snapshot_py_tool() {
9369
+ local c
9370
+ for c in /usr/bin/python3 /bin/python3; do
9371
+ [ -x "$c" ] && [ ! -d "$c" ] && "$c" -I -S -c '' >/dev/null 2>&1 && { printf '%s\n' "$c"; return 0; }
9372
+ done
9373
+ local dir
9374
+ local IFS=:
9375
+ for dir in $PATH; do
9376
+ case "$dir" in
9377
+ /*) ;;
9378
+ *) continue ;;
9379
+ esac
9380
+ if [ -x "$dir/python3" ] && [ ! -d "$dir/python3" ] \
9381
+ && "$dir/python3" -I -S -c '' >/dev/null 2>&1; then
9382
+ printf '%s\n' "$dir/python3"
9383
+ return 0
9384
+ fi
9385
+ done
9386
+ return 1
9387
+ }
9388
+
9389
+ # BACKLOG 129: same rationale and pattern as _loki_snapshot_py_tool above, for
9390
+ # `git`. Every call in this snapshot writer/reader chain
9391
+ # (_loki_untracked_status, _loki_tracked_by_user_since_anchor,
9392
+ # _loki_snapshot_preexisting's own `git rev-parse --show-toplevel`) used a bare
9393
+ # `git` -- an unscoped PATH lookup a same-UID attacker can shadow with a fake
9394
+ # binary placed in a user-writable directory ahead of the real one, exactly
9395
+ # the class of bypass _loki_snapshot_hash_tool's own comment already documents
9396
+ # for sha256sum/shasum. Reproduced empirically: a fake `git` ahead on PATH
9397
+ # that filters one path out of `git status`'s output caused that path to be
9398
+ # completely absent from the sealed snapshot. `_loki_untrack_agent_committed_
9399
+ # user_files`'s git calls (feeding the READER path, _loki_covered_paths) and
9400
+ # the commit-time `git reset`/`git commit`/`git update-index` calls in
9401
+ # commit_session_changes are DELIBERATELY left unresolved here (out of scope
9402
+ # for this fix): a fake git on PATH already defeats the commit gate directly
9403
+ # at commit time regardless of what the snapshot says, so resolving only the
9404
+ # reader's git feed would not change that attacker's outcome. Tracked as a
9405
+ # follow-up (process-wide resolved git) rather than folded into this change.
9406
+ _loki_snapshot_git_tool() {
9407
+ local c
9408
+ for c in /usr/bin/git /bin/git; do
9409
+ [ -x "$c" ] && [ ! -d "$c" ] && "$c" --version >/dev/null 2>&1 && { printf '%s\n' "$c"; return 0; }
9410
+ done
9411
+ local dir
9412
+ local IFS=:
9413
+ for dir in $PATH; do
9414
+ case "$dir" in
9415
+ /*) ;;
9416
+ *) continue ;;
9417
+ esac
9418
+ if [ -x "$dir/git" ] && [ ! -d "$dir/git" ] && "$dir/git" --version >/dev/null 2>&1; then
9419
+ printf '%s\n' "$dir/git"
9420
+ return 0
9421
+ fi
9422
+ done
9423
+ return 1
9424
+ }
9425
+
9426
+ # _loki_snapshot_hash_tool: resolve an absolute path to sha256sum or shasum.
9427
+ # Fixed, root-owned candidate paths are tried FIRST, before any PATH walk: a
9428
+ # same-UID agent that can write to a user-writable directory ahead of the real
9429
+ # tool on PATH (Homebrew's prefix, e.g. /opt/homebrew/bin, is user-writable by
9430
+ # default on macOS and typically precedes /usr/bin) could otherwise drop its
9431
+ # own sha256sum/shasum there that reads the two snapshot files and echoes back
9432
+ # whatever hash it wants -- the same class of bypass as the .pth hijack this
9433
+ # whole function exists to close, just with a fake binary as the carrier
9434
+ # instead of a fake module. Trying the standard system locations first closes
9435
+ # that: they require root to write to, a materially higher bar than a
9436
+ # user-writable site-packages directory or a user-writable PATH entry.
9437
+ # A PATH walk (D7: never trust a bare command name -- restricted to absolute
9438
+ # entries, matching autonomy/loki's loki_verify_tool_path) is kept only as a
9439
+ # last resort for a host that keeps sha256sum/shasum somewhere nonstandard;
9440
+ # document there that this fallback still trusts whatever is first on an
9441
+ # absolute PATH entry, which a user-writable directory ahead of the system
9442
+ # dirs can still shadow. Prints nothing (caller then falls back) when no tool
9443
+ # is found anywhere.
9444
+ _loki_snapshot_hash_tool() {
9445
+ local c
9446
+ for c in /usr/bin/sha256sum /bin/sha256sum /sbin/sha256sum \
9447
+ /usr/bin/shasum /bin/shasum; do
9448
+ [ -x "$c" ] && [ ! -d "$c" ] && { printf '%s\n' "$c"; return 0; }
9449
+ done
9450
+ # Last resort: PATH walk, absolute entries only. Still trusts whatever
9451
+ # sits first on PATH under that name; see comment above.
9452
+ local name dir
9453
+ local IFS=:
9454
+ for name in sha256sum shasum; do
9455
+ for dir in $PATH; do
9456
+ case "$dir" in
9457
+ /*) ;;
9458
+ *) continue ;;
9459
+ esac
9460
+ if [ -x "$dir/$name" ] && [ ! -d "$dir/$name" ]; then
9461
+ printf '%s\n' "$dir/$name"
9462
+ return 0
9463
+ fi
9464
+ done
9465
+ done
9466
+ return 1
9467
+ }
9468
+
9469
+ # _loki_snapshot_digest: sha256 of preexisting-untracked.z + .sha.z content
9470
+ # (each independently, "-" standing in for "file absent" so a delete is part
9471
+ # of the digest, not indistinguishable from an empty file; "?" standing in for
9472
+ # "file present but the hash tool failed to produce a real digest" -- a
9473
+ # nonzero exit, empty output, or output that fails validation below -- so a
9474
+ # transient read error never seals or compares as an empty string).
9475
+ #
9476
+ # HIGH-severity reviewer finding on the original python3 -E version: -E only
9477
+ # ignores PYTHON* env vars, it does NOT stop Python from loading user (or, on
9478
+ # macOS with Homebrew, system) site-packages, and Python executes every .pth
9479
+ # file found there on every startup. A same-UID agent process can plant a .pth
9480
+ # file there that hijacks this script's own print() to emit a cached, stale
9481
+ # (pre-tamper) digest -- fully defeating the seal while `git`/file state show
9482
+ # real tampering happened. Fixed by computing the digest with sha256sum/shasum
9483
+ # instead: neither has a .pth-style module-loading path for that hijack to
9484
+ # attach to. shasum is a Perl script on macOS (not a compiled binary), but its
9485
+ # @INC search path is root-owned system Perl library directories, not a user
9486
+ # site-packages directory, so the .pth carrier does not transfer to it.
9487
+ #
9488
+ # SECOND reviewer finding, on the shasum/sha256sum version that first closed
9489
+ # the .pth hole: the digest was piped through a bare `awk '{print $1}')`
9490
+ # resolved via an unscoped PATH lookup (`awk '{...}'` runs whatever `awk`
9491
+ # comes first on PATH). A fake `awk` placed in a user-writable directory ahead
9492
+ # of the real one (the same Homebrew-prefix-precedes-/usr/bin shape as the
9493
+ # hash-tool bypass above) can intercept the pipe and print a cached, stale
9494
+ # digest, defeating the seal exactly as before -- the fix reintroduced the
9495
+ # same bypass class it was written to close, just moved one command over.
9496
+ # Fixed by dropping awk entirely: the first whitespace-delimited field is
9497
+ # taken with the shell builtin `${h%% *}` (parameter expansion, no external
9498
+ # process, nothing on PATH to hijack), and the result is validated as exactly
9499
+ # 64 lowercase hex characters before being trusted as a real sha256 digest --
9500
+ # anything else (empty, wrong length, non-hex, a hand-rolled fake tool that
9501
+ # does not bother to format like sha256sum/shasum) becomes "?". The hash
9502
+ # tool's own exit status is also checked explicitly now: a piped
9503
+ # `cmd | awk ...` hid a nonzero exit from the hash tool behind awk's exit
9504
+ # status; capturing to a plain variable surfaces it.
9505
+ #
9506
+ # Resolved via _loki_snapshot_hash_tool above (fixed root-owned paths first,
9507
+ # PATH walk restricted to absolute entries only as a last resort), not a bare
9508
+ # `command -v`, so this also closes the sibling D7-pattern gap: an unscoped
9509
+ # lookup could otherwise resolve to a same-named binary planted in a
9510
+ # user-writable directory ahead of the real tool on PATH.
9511
+ #
9512
+ # Accepted gaps (disclosure, not prevention, per BACKLOG 70's own bar, same as
9513
+ # documented at _LOKI_SNAPSHOT_SEAL above): a same-UID ptrace attack on this
9514
+ # process or on the sha256sum/shasum child process itself; replacing the
9515
+ # sha256sum/shasum binary AT ONE OF THE FIXED SYSTEM PATHS this resolves to
9516
+ # first (requires root, a materially higher bar than a user-writable
9517
+ # site-packages .pth or a user-writable PATH entry); on a host with none of
9518
+ # the fixed candidates, the PATH-walk fallback inside _loki_snapshot_hash_tool
9519
+ # still trusts whatever sits first on an absolute PATH entry under that name,
9520
+ # which a user-writable directory ahead of the system dirs (e.g. Homebrew's
9521
+ # prefix on macOS) can shadow -- not fully closed, only narrowed to hosts
9522
+ # lacking the standard tool paths; the python3 fallback below, kept only for
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.
9532
+ _loki_snapshot_digest() {
9533
+ local tool
9534
+ tool="$(_loki_snapshot_hash_tool)" || tool=""
9535
+ if [ -n "$tool" ]; then
9536
+ local out="" path h
9537
+ for path in ".loki/state/preexisting-untracked.z" ".loki/state/preexisting-untracked.sha.z"; do
9538
+ if [ -f "$path" ]; then
9539
+ h=""
9540
+ case "${tool##*/}" in
9541
+ shasum) h="$("$tool" -a 256 -- "$path" 2>/dev/null)" || h="" ;;
9542
+ *) h="$("$tool" -- "$path" 2>/dev/null)" || h="" ;;
9543
+ esac
9544
+ # No awk: take the first whitespace-delimited field with a
9545
+ # shell builtin (nothing on PATH to hijack), then require
9546
+ # exactly 64 lowercase hex characters. Anything else -- empty
9547
+ # output, a nonzero exit already caught above, an unexpected
9548
+ # format, a hand-rolled fake tool -- becomes "?" rather than
9549
+ # being trusted as a real digest. "?" is distinct from "-"
9550
+ # (file absent) and from any real hex digest, so a later
9551
+ # successful read of the same file mismatches it and is still
9552
+ # disclosed as tampering.
9553
+ h="${h%% *}"
9554
+ if [ "${#h}" -eq 64 ]; then
9555
+ case "$h" in
9556
+ *[!0123456789abcdef]*) h="?" ;;
9557
+ esac
9558
+ else
9559
+ h="?"
9560
+ fi
9561
+ out="$out $h"
9562
+ else
9563
+ out="$out -"
9564
+ fi
9565
+ done
9566
+ printf '%s\n' "${out# }"
9567
+ return 0
9568
+ fi
9569
+ # Fallback: neither sha256sum nor shasum found anywhere checked above
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
9581
+ # accepted gap specific to this fallback (not present on the tool-based
9582
+ # path above, which distinguishes "-" from "?"): OSError covers BOTH
9583
+ # "file does not exist" and "file exists but is unreadable" here, so an
9584
+ # unreadable file on this path is indistinguishable from an absent one --
9585
+ # both print "-". The tool-based path above tells them apart ("-" for
9586
+ # absent, "?" for present-but-unreadable, caught by the seal-time
9587
+ # fail-closed check). Narrowing this further (os.path.lexists to emit "?"
9588
+ # for present-but-unreadable) is straightforward but left undone here: this
9589
+ # fallback only runs on a host with neither sha256sum nor shasum anywhere,
9590
+ # already the rare case this whole function treats as a residual gap.
9591
+ local pytool=""
9592
+ pytool="$(_loki_snapshot_py_tool)" || return 1
9593
+ "$pytool" -I -S -c 'import sys, hashlib
9594
+ sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9595
+ out = []
9596
+ for path in sys.argv[1:]:
9597
+ try:
9598
+ with open(path, "rb") as fh:
9599
+ out.append(hashlib.sha256(fh.read()).hexdigest())
9600
+ except OSError:
9601
+ out.append("-")
9602
+ print(" ".join(out))' \
9603
+ ".loki/state/preexisting-untracked.z" ".loki/state/preexisting-untracked.sha.z" 2>/dev/null
9604
+ }
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
+
9755
+ # _loki_snapshot_seal: record this process's own memory of what it just wrote.
9756
+ # Called only right after a successful _loki_snapshot_preexisting. Minor
9757
+ # reviewer note: a legitimate snapshot write should never hit an unreadable-
9758
+ # file condition immediately after writing it, so a "?" in the digest AT SEAL
9759
+ # TIME (as opposed to at a later verify) is itself suspicious -- both seal and
9760
+ # a later verify could then read "?", compare equal, and silently disarm the
9761
+ # guard for the rest of the session (the same failure mode
9762
+ # _loki_snapshot_verify's mismatch path exists to catch, just never triggered
9763
+ # because both sides agree on the sentinel). Closed cheaply: reject an empty
9764
+ # or "?"-containing digest at seal time the same way _loki_snapshot_verify
9765
+ # rejects a mismatch -- log and disclose it, write the .failed marker, AND
9766
+ # clear _LOKI_SNAPSHOT_THIS_RUN directly here (belt-and-braces: the actual
9767
+ # gate that stops a commit). The call site right after `_loki_snapshot_seal`
9768
+ # in _loki_snapshot_preexisting also checks `_LOKI_SNAPSHOT_SEAL` and returns
9769
+ # non-zero on failure, which is what actually propagates to
9770
+ # _loki_snapshot_or_fail_closed / _loki_resume_snapshot and keeps their
9771
+ # existing callers from ever setting _LOKI_SNAPSHOT_THIS_RUN=1 in the first
9772
+ # place (see the comment there) -- so the in-function clear here is redundant
9773
+ # with that, not the sole mechanism, kept only as defense in depth in case a
9774
+ # future caller invokes this function directly. The call site above is
9775
+ # intentionally left as a bare `_loki_snapshot_seal` (no `|| return`):
9776
+ # tests/test-branch-lifecycle.sh's RED harness matches that exact line to
9777
+ # strip the guard; the propagation check right after it carries its own
9778
+ # trailing-comment marker the RED harness also strips (see the comment at
9779
+ # that check, in _loki_snapshot_preexisting, for the exact text -- not
9780
+ # repeated here so a grep for it does not also match this sentence), so
9781
+ # removing either half of the guard is enough to reproduce the pre-fix
9782
+ # silent-degrade behavior in that test.
9783
+ _loki_snapshot_seal() {
9784
+ local d
9785
+ d="$(_loki_snapshot_digest)"
9786
+ case "$d" in
9787
+ ""|*'?'*)
9788
+ _LOKI_SNAPSHOT_SEAL=""
9789
+ _LOKI_SNAPSHOT_THIS_RUN=0
9790
+ log_warn "Could not seal the pre-existing-file snapshot (hash tool failed or produced no readable digest); treating this session as unsnapshotted (nothing will be committed)"
9791
+ audit_log "SNAPSHOT_SEAL_FAILED" "_loki_snapshot_digest returned empty or an unreadable-file sentinel at seal time"
9792
+ mkdir -p .loki/state 2>/dev/null
9793
+ : > .loki/state/preexisting-untracked.failed 2>/dev/null
9794
+ ;;
9795
+ *)
9796
+ _LOKI_SNAPSHOT_SEAL="$d"
9797
+ ;;
9798
+ esac
9799
+ }
9800
+
9801
+ # _loki_snapshot_verify: compare the live files against the sealed digest.
9802
+ # No seal recorded yet (never snapshotted this process, or deliberately
9803
+ # cleared) is not tampering: returns success and does nothing. A seal that no
9804
+ # longer matches IS tampering: logs it (disclosure is the bar BACKLOG 70 asks
9805
+ # for; a same-UID process can rewrite anything else this one could check), and
9806
+ # forces every fail-closed path a missing/failed snapshot already takes --
9807
+ # clears the seal so a caller cannot re-check a stale value, clears
9808
+ # _LOKI_SNAPSHOT_THIS_RUN so commit_session_changes and
9809
+ # _loki_record_session_created both treat this as an unsnapshotted session, and
9810
+ # (re)writes preexisting-untracked.failed so BACKLOG 90's existing message
9811
+ # fires. Every consumer of the snapshot/hash files calls this first and treats
9812
+ # non-zero as "no snapshot".
9813
+ _loki_snapshot_verify() {
9814
+ local live=""
9815
+ [ -n "$_LOKI_SNAPSHOT_SEAL" ] || return 0
9816
+ live="$(_loki_snapshot_digest)"
9817
+ if [ "$live" = "$_LOKI_SNAPSHOT_SEAL" ]; then
9818
+ return 0
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"
9825
+ _LOKI_SNAPSHOT_SEAL=""
9826
+ _LOKI_SNAPSHOT_THIS_RUN=0
9827
+ mkdir -p .loki/state 2>/dev/null
9828
+ : > .loki/state/preexisting-untracked.failed 2>/dev/null
9829
+ return 1
9830
+ }
9831
+
9216
9832
  # _loki_snapshot_preexisting [union]
9217
9833
  # Write .loki/state/preexisting-untracked.z: every path git does not track,
9218
9834
  # untracked and gitignored, repo-wide, NUL-delimited, relative to the repo top.
@@ -9221,33 +9837,90 @@ _loki_snapshot_or_fail_closed() {
9221
9837
  # a directory only when an ignore pattern matches it, so node_modules/ is one
9222
9838
  # "dir/" entry covering its subtree, while logs/ holding only *.log files is
9223
9839
  # listed file by file (a new logs/app.json is still the agent's). "union" keeps
9224
- # the paths already recorded. The sibling .sha.z (content hash per file entry)
9225
- # lets the receipt list a pre-existing file the run changed; it is removed
9226
- # first, so a failed hash step means no detection, never stale hashes. Returns
9227
- # non-zero, leaving any earlier list in place, when git cannot list.
9840
+ # the paths already recorded, minus any that became tracked because the USER
9841
+ # committed them between sessions (BACKLOG 68 rework). Distinguishing that from
9842
+ # "the AGENT itself committed or staged this pre-existing file during THIS
9843
+ # session, then got killed" is the entire point of the anchor below: only a
9844
+ # path git ADDED strictly after the last point this run itself could reach is
9845
+ # ever pruned. A path the agent tracked before that point is left IN the union
9846
+ # so it still flows into _loki_untrack_agent_committed_user_files's disclosure
9847
+ # (warn, remove from the branch tip, keep on disk) -- never silently swept in.
9848
+ #
9849
+ # The anchor is .loki/state/tracked-since.sha, a commit SHA written by
9850
+ # _loki_record_session_created (after each provider turn) and by
9851
+ # commit_session_changes (at session end): the newest point this run itself
9852
+ # recorded. .loki/state/turn-in-flight marks a provider turn as currently
9853
+ # running (set right before invocation, cleared by the same record call that
9854
+ # advances the anchor); its presence means a turn may have committed something
9855
+ # since the last anchor write, so pruning is skipped entirely rather than risk
9856
+ # treating an agent's mid-turn commit as the user's. Any anchor-resolution
9857
+ # failure (missing anchor, anchor not an ancestor of HEAD, merge-base failure)
9858
+ # also skips pruning: the safe direction is the old behavior (false blame, file
9859
+ # kept on disk), never silent adoption.
9860
+ #
9861
+ # The sibling .sha.z (content hash per file entry) lets the receipt list a
9862
+ # pre-existing file the run changed; it is removed first, so a failed hash step
9863
+ # means no detection, never stale hashes. Returns non-zero, leaving any earlier
9864
+ # list in place, when git cannot list. Seals a digest of both files on success
9865
+ # (BACKLOG 70) so later reads can detect tampering.
9228
9866
  _loki_snapshot_preexisting() {
9229
- local snap=".loki/state/preexisting-untracked.z" top="" base="" exclude=""
9867
+ local snap=".loki/state/preexisting-untracked.z" top="" base="" exclude="" tracked="" gittool="" pytool=""
9230
9868
  rm -f "${snap%.z}.sha.z" 2>/dev/null
9231
- top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
9869
+ gittool="$(_loki_snapshot_git_tool)" || return 1
9870
+ top="$("$gittool" rev-parse --show-toplevel 2>/dev/null)" || return 1
9232
9871
  mkdir -p .loki/state 2>/dev/null || return 1
9233
9872
  # "union" never adds a path listed in session-created.z: those files were
9234
9873
  # made by a session that did not finish (interrupt, pod loss), so the
9235
9874
  # session that finishes the work commits them. If the user edits one of
9236
9875
  # them between sessions it is still the session's file and is committed
9237
- # with the edit. Recorded entries only block additions; they never remove
9238
- # a path already in the list.
9876
+ # with the edit. Recorded entries only block additions; a path this run can
9877
+ # prove the USER tracked between sessions is the one case they DO remove
9878
+ # (see _loki_tracked_by_user_since_anchor below).
9239
9879
  if [ "${1:-}" = union ]; then
9240
9880
  base="$snap"
9241
9881
  exclude=".loki/state/session-created.z"
9882
+ tracked="$snap.tracked"
9883
+ if ! _loki_tracked_by_user_since_anchor "$top" "$tracked"; then
9884
+ rm -f "$tracked"
9885
+ tracked=""
9886
+ fi
9242
9887
  fi
9243
9888
  if ! _loki_untracked_status "$snap.status" \
9244
- || ! _loki_untracked_merge "$snap.status" "$base" "$exclude" exact "$snap"; then
9245
- rm -f "$snap.status" "$snap.tmp"
9889
+ || ! _loki_untracked_merge "$snap.status" "$base" "$exclude" exact "$snap" "$tracked"; then
9890
+ rm -f "$tracked" "$snap.status" "$snap.tmp"
9246
9891
  return 1
9247
9892
  fi
9248
- rm -f "$snap.status"
9249
- python3 -E "$SCRIPT_DIR/lib/workspace_diff.py" hash-snapshot "$top" "$snap" >/dev/null 2>&1 \
9893
+ rm -f "$tracked" "$snap.status"
9894
+ # BACKLOG 129: write_snapshot_hashes (the only code path "hash-snapshot"
9895
+ # reaches) does pure file I/O (open/os.path/hashlib), no sibling import
9896
+ # from autonomy/lib and no subprocess/git call, so -I -S is safe here: it
9897
+ # never needs the script's own directory on sys.path or anything from
9898
+ # site-packages. This writes .sha.z, one of the two files the seal above
9899
+ # digests, so it is in scope of the same forgery class as the merge/covered
9900
+ # sites below. A failed resolve here degrades to the existing log_warn path
9901
+ # (receipt loses preexisting_modified detection) rather than blocking the
9902
+ # snapshot -- matching this call's pre-existing non-fatal-on-failure shape.
9903
+ pytool="$(_loki_snapshot_py_tool)" && "$pytool" -I -S "$SCRIPT_DIR/lib/workspace_diff.py" hash-snapshot "$top" "$snap" >/dev/null 2>&1 \
9250
9904
  || log_warn "Could not hash your pre-existing untracked files; the receipt cannot list the ones this run changes"
9905
+ _loki_snapshot_seal
9906
+ # A seal failure (empty digest or an unreadable-file "?", see the comment
9907
+ # at _loki_snapshot_seal) leaves _LOKI_SNAPSHOT_SEAL empty; propagate that
9908
+ # as a failure of this function too, so both callers'
9909
+ # (_loki_snapshot_or_fail_closed and _loki_resume_snapshot) existing
9910
+ # fail-closed handling applies, instead of silently reporting success on a
9911
+ # session this process could not actually seal. This is the line that
9912
+ # actually makes a seal failure fail closed (_loki_snapshot_seal's own
9913
+ # in-function clear is defense in depth, not the mechanism -- see the
9914
+ # comment there). This line is deliberately also stripped by
9915
+ # tests/test-branch-lifecycle.sh's BACKLOG-70 RED mutation, by its trailing
9916
+ # comment marker (kept off this sentence on purpose, so a grep for the
9917
+ # marker text matches only the one real code line below, not this prose),
9918
+ # same as the guard calls above it: without it, on a mutated copy that
9919
+ # also strips `_loki_snapshot_seal`, _LOKI_SNAPSHOT_SEAL stays at its
9920
+ # global initial value ("") and this check alone would still return 1 and
9921
+ # fail closed, masking the RED reproduction the test relies on to prove
9922
+ # the mutation of the ACTUAL guard calls is non-vacuous.
9923
+ [ -n "$_LOKI_SNAPSHOT_SEAL" ] || return 1 # BACKLOG-70-SEAL-CHECK
9251
9924
  return 0
9252
9925
  }
9253
9926
 
@@ -9255,32 +9928,57 @@ _loki_snapshot_preexisting() {
9255
9928
  # and the session-created record, so the two always agree: raw
9256
9929
  # `git status --porcelain -z` records for the whole repo, .loki excluded.
9257
9930
  # Non-zero, writing nothing, when git cannot list.
9931
+ # BACKLOG 129: resolved via _loki_snapshot_git_tool (fixed root-owned path
9932
+ # first, PATH walk restricted to absolute entries as a last resort), not a
9933
+ # bare `git`, which a same-UID attacker can shadow with a fake binary on a
9934
+ # user-writable PATH entry ahead of the real one -- reproduced empirically: a
9935
+ # fake git filtering one path out of its `status` output made that path
9936
+ # entirely absent from the sealed snapshot this function ultimately feeds.
9258
9937
  _loki_untracked_status() {
9259
- local top="" prefix=""
9260
- top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
9261
- prefix="$(git rev-parse --show-prefix 2>/dev/null)" || return 1
9262
- git -C "$top" --no-optional-locks status --porcelain -z --no-renames -uall \
9938
+ local top="" prefix="" gittool=""
9939
+ gittool="$(_loki_snapshot_git_tool)" || return 1
9940
+ top="$("$gittool" rev-parse --show-toplevel 2>/dev/null)" || return 1
9941
+ prefix="$("$gittool" rev-parse --show-prefix 2>/dev/null)" || return 1
9942
+ "$gittool" -C "$top" --no-optional-locks status --porcelain -z --no-renames -uall \
9263
9943
  --ignored=matching --ignore-submodules=all -- ":(exclude,literal)${prefix}.loki" \
9264
9944
  > "$1" 2>/dev/null && return 0
9265
9945
  rm -f "$1"
9266
9946
  return 1
9267
9947
  }
9268
9948
 
9269
- # _loki_untracked_merge <status> <base> <exclude> exact|cover <out>
9270
- # Atomically write <out>: the NUL-delimited paths of <base>, plus every
9271
- # untracked ("??") or ignored ("!!") path in <status> that <exclude> does not
9272
- # hold. With "cover", an <exclude> entry ending in "/" also holds every path
9273
- # below it (workspace_diff._covered). An empty or missing <base> or <exclude>
9274
- # is an empty list; any other read or write failure is non-zero and leaves
9275
- # <out> as it was. Sorted bytewise, like `LC_ALL=C sort -z -u`. One python
9276
- # process: bash 3.2 has no associative arrays, and a bash loop cost 300ms per
9277
- # 6,000 entries on /bin/bash. -E and no cwd on sys.path (D7): the cwd is the
9278
- # agent's repo.
9949
+ # _loki_untracked_merge <status> <base> <exclude> exact|cover <out> [tracked]
9950
+ # Atomically write <out>: the NUL-delimited paths of <base> minus any path
9951
+ # <tracked> lists (BACKLOG 68: entries the USER tracked between sessions, as
9952
+ # resolved by _loki_tracked_by_user_since_anchor -- never the whole index),
9953
+ # plus every untracked ("??") or ignored ("!!") path in <status> that <exclude>
9954
+ # does not hold. With "cover", an <exclude> entry ending in "/" also holds
9955
+ # every path below it (workspace_diff._covered). An empty or missing <base> or
9956
+ # <exclude> is an empty list; any other read or write failure is non-zero and
9957
+ # leaves <out> as it was. Sorted bytewise, like `LC_ALL=C sort -z -u`. One
9958
+ # python process: bash 3.2 has no associative arrays, and a bash loop cost
9959
+ # 300ms per 6,000 entries on /bin/bash. -I -S and no cwd on sys.path (D7): the
9960
+ # cwd is the agent's repo.
9961
+ # BACKLOG 129: this is the snapshot WRITER -- its <out> becomes
9962
+ # preexisting-untracked.z, one of the two files the seal above digests. A bare
9963
+ # `python3 -E` here still loads user site-packages (-E only blocks PYTHON* env
9964
+ # vars), so a same-UID .pth planted there can run arbitrary code during this
9965
+ # call and rewrite <out> after the legitimate write, forging exactly the bytes
9966
+ # the seal is about to trust -- reproduced empirically (a .pth that stripped
9967
+ # an entry from the just-written output). Resolved via _loki_snapshot_py_tool
9968
+ # (fixed root-owned path first) and run with -I -S: -I alone is not enough
9969
+ # (verified: `python3 -I` alone still lists Homebrew's user-writable
9970
+ # site-packages on this host), -S additionally skips `import site` so no .pth
9971
+ # anywhere is ever processed. A resolve failure fails this function closed
9972
+ # (return 1, same as any other read/write failure here) rather than silently
9973
+ # falling back to a bare, unresolved `python3`.
9279
9974
  _loki_untracked_merge() {
9280
- python3 -E -c 'import sys
9975
+ local pytool=""
9976
+ pytool="$(_loki_snapshot_py_tool)" || return 1
9977
+ "$pytool" -I -S -c 'import sys
9281
9978
  sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9282
9979
  import os
9283
9980
  status, base, exclude, mode, out = sys.argv[1:6]
9981
+ tracked = sys.argv[6] if len(sys.argv) > 6 else ""
9284
9982
  def entries(path, missing_ok=True):
9285
9983
  try:
9286
9984
  with open(path, "rb") as fh:
@@ -9300,6 +9998,11 @@ def covered(path):
9300
9998
  cut = path.find(b"/", cut + 1)
9301
9999
  return False
9302
10000
  paths = set(entries(base)) if base else set()
10001
+ if tracked:
10002
+ # Exact match only: a "dir/" entry is never dropped even if a file under it
10003
+ # is now tracked, so an ignored directory (node_modules/) never re-exposes
10004
+ # the rest of its subtree (BACKLOG 66/78 territory).
10005
+ paths -= set(entries(tracked))
9303
10006
  for rec in entries(status, missing_ok=False):
9304
10007
  # The session-created record ("cover" mode) never holds a directory entry:
9305
10008
  # a whole-directory entry (logs/, out/) would stop the resume union from
@@ -9315,6 +10018,80 @@ with open(out + ".tmp", "wb") as fh:
9315
10018
  os.replace(out + ".tmp", out)' "$@" 2>/dev/null
9316
10019
  }
9317
10020
 
10021
+ # _loki_tracked_by_user_since_anchor <top> <out> (BACKLOG 68 rework)
10022
+ # Write <out>: the NUL-delimited paths git added to the index between the
10023
+ # recorded "tracked-since" anchor and HEAD -- i.e. what a resume union may
10024
+ # safely treat as "the USER tracked this between sessions" and prune. Returns
10025
+ # non-zero (writing nothing) whenever that inference is not safe, which leaves
10026
+ # the caller pruning nothing (the old, safe-by-default behavior):
10027
+ # - no anchor recorded yet (an older session, or nothing survived a turn)
10028
+ # - a provider turn is marked in-flight (.loki/state/turn-in-flight): a turn
10029
+ # can commit or stage a file and then be SIGKILLed before the next record
10030
+ # call advances the anchor, so anything since the LAST anchor is unproven
10031
+ # - the anchor does not resolve, or is not an ancestor of HEAD (branch
10032
+ # switched, history rewritten, anchor stale)
10033
+ # --diff-filter=A, --no-renames: only additions, matching
10034
+ # _loki_untrack_agent_committed_user_files's own diff so the two never
10035
+ # disagree about which paths are "added since X".
10036
+ # BACKLOG 129: <out> here is subtracted from the snapshot base inside
10037
+ # _loki_untracked_merge (the "tracked" argument) before that WRITER seals its
10038
+ # result -- a fake git that lists an extra path here can prune a real
10039
+ # untracked path (e.g. a secret) out of the sealed snapshot, the same attack
10040
+ # as a forged `git status`, just one function over. Resolved via
10041
+ # _loki_snapshot_git_tool like the other git calls in this writer chain.
10042
+ _loki_tracked_by_user_since_anchor() {
10043
+ local top="$1" out="$2" anchor_file=".loki/state/tracked-since.sha" anchor="" gittool=""
10044
+ [ -f .loki/state/turn-in-flight ] && return 1
10045
+ [ -s "$anchor_file" ] || return 1
10046
+ anchor="$(cat "$anchor_file" 2>/dev/null)"
10047
+ [ -n "$anchor" ] || return 1
10048
+ gittool="$(_loki_snapshot_git_tool)" || return 1
10049
+ "$gittool" rev-parse --verify -q "$anchor" >/dev/null 2>&1 || return 1
10050
+ "$gittool" merge-base --is-ancestor "$anchor" HEAD 2>/dev/null || return 1
10051
+ "$gittool" -C "$top" diff --name-only -z --no-renames --diff-filter=A "$anchor" HEAD > "$out.tmp" 2>/dev/null \
10052
+ || { rm -f "$out.tmp"; return 1; }
10053
+ mv -f "$out.tmp" "$out"
10054
+ return 0
10055
+ }
10056
+
10057
+ # _loki_advance_tracked_since_anchor
10058
+ # Move .loki/state/tracked-since.sha to the current HEAD and clear
10059
+ # .loki/state/turn-in-flight, but ONLY once the anchor write is confirmed on
10060
+ # disk. Called after every provider turn (_loki_record_session_created) and at
10061
+ # normal session end (commit_session_changes), i.e. at every point this run
10062
+ # can prove no provider turn is currently running -- PROVIDED the anchor write
10063
+ # itself lands, since _loki_tracked_by_user_since_anchor trusts both signals
10064
+ # together (anchor position + in-flight marker) to prove a commit happened
10065
+ # strictly after the anchor with no turn running. Clearing the marker on a
10066
+ # failed write would decouple them: a stale anchor stays on disk while the
10067
+ # marker falsely reports "no turn was in flight", so a later resume could
10068
+ # prove something it can't actually prove and prune an agent's own commit with
10069
+ # no disclosure (this is BACKLOG 68's failure mode, reintroduced). A HEAD that
10070
+ # does not resolve (unborn branch) is not a write failure -- there is nothing
10071
+ # to anchor yet -- so the marker still clears; the anchor stays "not set" as
10072
+ # before. Always returns 0 (both call sites are bare, unchecked calls): the
10073
+ # in-flight marker, not the return code, is the safety signal on a failed
10074
+ # write.
10075
+ _loki_advance_tracked_since_anchor() {
10076
+ local sha=""
10077
+ sha="$(git rev-parse --verify -q HEAD 2>/dev/null)" || true
10078
+ if [ -n "$sha" ]; then
10079
+ if ! { printf '%s\n' "$sha" > .loki/state/tracked-since.sha.tmp 2>/dev/null \
10080
+ && mv -f .loki/state/tracked-since.sha.tmp .loki/state/tracked-since.sha 2>/dev/null; }; then
10081
+ # Anchor write failed (disk full, a stray .tmp collision, a
10082
+ # concurrent writer). Deliberately leave turn-in-flight SET and
10083
+ # return here, before the unconditional clear below: the safe
10084
+ # direction is to keep the old fallback-to-no-pruning path active
10085
+ # for one more check, never to clear a marker the anchor write did
10086
+ # not earn.
10087
+ rm -f .loki/state/tracked-since.sha.tmp 2>/dev/null
10088
+ return 0
10089
+ fi
10090
+ fi
10091
+ rm -f .loki/state/turn-in-flight 2>/dev/null
10092
+ return 0
10093
+ }
10094
+
9318
10095
  # _loki_record_session_created
9319
10096
  # Union into .loki/state/session-created.z (NUL-delimited, repo-top-relative,
9320
10097
  # atomic) the paths git does not track now that the pre-existing snapshot does
@@ -9331,10 +10108,54 @@ os.replace(out + ".tmp", out)' "$@" 2>/dev/null
9331
10108
  # disk, not committed).
9332
10109
  _loki_record_session_created() {
9333
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
9334
10141
  [ "${_LOKI_SNAPSHOT_THIS_RUN:-0}" = 1 ] || return 0
10142
+ # BACKLOG 70: detect tampering with the sealed snapshot as early as
10143
+ # possible (this runs after every provider turn, not just at commit time).
10144
+ _loki_snapshot_verify || return 1
10145
+ # BACKLOG 68 rework: this call is the point right after a provider turn
10146
+ # ends, so it also advances the tracked-since anchor and clears the
10147
+ # in-flight marker -- whatever the agent committed or staged during the
10148
+ # turn that just finished is now provably NOT mid-turn, so a later resume's
10149
+ # anchor..HEAD diff may safely be checked against it.
10150
+ _loki_advance_tracked_since_anchor
9335
10151
  if [ -f "$snap" ] && _loki_untracked_status "$out.status" \
9336
10152
  && _loki_untracked_merge "$out.status" "$out" "$snap" cover "$out"; then
9337
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
9338
10159
  return 0
9339
10160
  fi
9340
10161
  rm -f "$out.status" "$out.tmp" 2>/dev/null
@@ -9346,6 +10167,19 @@ _loki_record_session_created() {
9346
10167
  # from an unfinished previous session (this session's commit includes them).
9347
10168
  _loki_resume_snapshot() {
9348
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
9349
10183
  if ! _loki_snapshot_or_fail_closed union; then
9350
10184
  log_warn "Could not add your current untracked files to the pre-existing list; this session will commit nothing (review and commit manually)"
9351
10185
  return 0
@@ -9389,10 +10223,21 @@ if ! type _commit_path_looks_secret >/dev/null 2>&1; then _commit_path_looks_sec
9389
10223
  # _loki_covered_paths <paths> <snapshot> <top> <out>
9390
10224
  # Atomically write <out>: the NUL-delimited entries of <paths> that <snapshot>
9391
10225
  # covers (the entry itself or a parent "dir/" entry, as workspace_diff._covered)
9392
- # and that still exist under <top>. Non-zero on a read or write failure. -E and
9393
- # no cwd on sys.path (D7): the cwd is the agent's repo.
10226
+ # and that still exist under <top>. Non-zero on a read or write failure. -I -S
10227
+ # and no cwd on sys.path (D7): the cwd is the agent's repo.
10228
+ # BACKLOG 129: this is the snapshot READER -- it reads <snapshot>
10229
+ # (preexisting-untracked.z, sealed content) and decides which of the agent's
10230
+ # newly-added paths are actually the user's pre-existing files (to be removed
10231
+ # from the branch tip, see _loki_untrack_agent_committed_user_files). A bare
10232
+ # `python3 -E` here has the same .pth exposure as the writer above: a same-UID
10233
+ # .pth can run arbitrary code during this call, including rewriting <out>
10234
+ # after the legitimate write. Resolved via _loki_snapshot_py_tool, run with
10235
+ # -I -S (see _loki_untracked_merge's comment for why -S, not just -I, is
10236
+ # required to close it). A resolve failure fails this function closed.
9394
10237
  _loki_covered_paths() {
9395
- python3 -E -c 'import sys
10238
+ local pytool=""
10239
+ pytool="$(_loki_snapshot_py_tool)" || return 1
10240
+ "$pytool" -I -S -c 'import sys
9396
10241
  sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9397
10242
  import os
9398
10243
  paths, snap, top, out = sys.argv[1:5]
@@ -9437,6 +10282,13 @@ _loki_untrack_agent_committed_user_files() {
9437
10282
  local snap=".loki/state/preexisting-untracked.z" rec=".loki/state/agent-committed-user-files.z"
9438
10283
  local base="" fork="" start="" top="" names=""
9439
10284
  local unchecked="Before switching branches, run git rm --cached on each of your files the agent committed and commit, or a checkout of the base deletes them. Left uncommitted: no session commit."
10285
+ # BACKLOG 70: a tampered snapshot cannot be trusted to say which paths were
10286
+ # pre-existing, so treat it the same as "no snapshot" -- fail closed via the
10287
+ # caller, which then skips the session commit entirely.
10288
+ if ! _loki_snapshot_verify; then
10289
+ log_warn "Could not check whether the agent committed your pre-existing untracked files on $1 (snapshot tampered). ${unchecked}"
10290
+ return 1
10291
+ fi
9440
10292
  [ -s "$snap" ] || return 0
9441
10293
  # No commit on the branch yet: the agent committed nothing.
9442
10294
  git rev-parse --verify -q HEAD >/dev/null 2>&1 || return 0
@@ -9488,6 +10340,18 @@ commit_session_changes() {
9488
10340
  command -v git >/dev/null 2>&1 || return 0
9489
10341
  git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
9490
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
+
9491
10355
  # Only act when a session feature branch was set up. This preserves the
9492
10356
  # LOCK A1 opt-out contract (LOKI_BRANCH_PROTECTION=false -> no agent-branch.txt
9493
10357
  # -> we never commit on the user's own branch), and also no-ops the detached
@@ -9515,6 +10379,15 @@ commit_session_changes() {
9515
10379
  ;;
9516
10380
  esac
9517
10381
 
10382
+ # BACKLOG 70: a snapshot tampered with since this session sealed it (an
10383
+ # agent process deleting the hash file to suppress preexisting_modified
10384
+ # disclosure, or editing either file to hide or falsely disown paths) is
10385
+ # treated as no snapshot at all: on a mismatch, _loki_snapshot_verify
10386
+ # clears _LOKI_SNAPSHOT_THIS_RUN and writes the same .failed marker a
10387
+ # snapshot failure writes, so the existing fail-closed check below
10388
+ # (BACKLOG 90) also catches tampering, with its own warning.
10389
+ _loki_snapshot_verify || true
10390
+
9518
10391
  # Commit nothing unless THIS run took a current snapshot (BACKLOG 90). The
9519
10392
  # LOCK A1 opt-out takes none, so it never commits, even on a leftover
9520
10393
  # loki/session-* branch whose agent-branch.txt and snapshot belong to an
@@ -9532,6 +10405,15 @@ commit_session_changes() {
9532
10405
  return 0
9533
10406
  fi
9534
10407
 
10408
+ # BACKLOG 68 rework: session end is also a point this run can prove no
10409
+ # provider turn is in flight, so advance the tracked-since anchor here too
10410
+ # (not only after each provider turn). Without this, a session that never
10411
+ # calls _loki_record_session_created between its last turn and a normal
10412
+ # end (e.g. a single-iteration run) would leave the anchor stale, and the
10413
+ # NEXT session's resume union would then have nothing to safely prune even
10414
+ # for a genuine user commit made between sessions.
10415
+ _loki_advance_tracked_since_anchor
10416
+
9535
10417
  _loki_untrack_agent_committed_user_files "$cur" || return 0
9536
10418
 
9537
10419
  # Stage everything except .loki/ runtime state and a secret-path denylist.
@@ -12725,14 +13607,26 @@ TREOF
12725
13607
  # summary the old best-effort parse of the tail still applies (null when it
12726
13608
  # finds nothing). Either way a recorded count above zero is a failure: the
12727
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).
12728
13619
  # LC_ALL=C: bytes, so any runner output parses; [ \t] not [[:space:]] for
12729
13620
  # old mawk.
12730
13621
  local _tr_passed_n _tr_failed_n _tr_summary_red=false
12731
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 '
12732
13623
  function upd(n) { if (n > f) f = n; seen = 1 }
12733
13624
  /^Tests:[ \t]/ || /^[ \t]*Tests[ \t]+[0-9]/ ||
12734
- /^=+ .*[0-9]+ (passed|failed)/ || /^[0-9]+ (passed|failed).* in [0-9.]+s/ {
12735
- 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) }
12736
13630
  /^[^ A-Za-z0-9]+ fail [0-9]+[ \t]*$/ { if (prev ~ /^[^ A-Za-z0-9]+ pass [0-9]+[ \t]*$/) upd($3 + 0) }
12737
13631
  /^[ \t]*[0-9]+ passing \(/ { upd(0) }
12738
13632
  /^[ \t]*[0-9]+ failing[ \t]*$/ { upd($1 + 0) }
@@ -13085,8 +13979,8 @@ if not isinstance(d, dict):
13085
13979
  verdict = d.get('verdict', 'unknown')
13086
13980
  diff = d.get('diff', {}) if isinstance(d.get('diff'), dict) else {}
13087
13981
  tests = d.get('tests', {}) if isinstance(d.get('tests'), dict) else {}
13088
- diff_ok = diff.get('ok')
13089
- 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')
13090
13984
  runner = tests.get('runner', 'none')
13091
13985
  parts = ['verdict=%s' % verdict]
13092
13986
  parts.append('diff_ok=%s' % diff_ok)
@@ -19331,6 +20225,12 @@ PYEOF
19331
20225
  _intelligent_usage_regen() {
19332
20226
  local target_dir="${TARGET_DIR:-.}"
19333
20227
  local usage_path="$target_dir/USAGE.md"
20228
+ # Only the provider the operator chose may see a prompt (V10 P5): on any
20229
+ # other provider keep the agent-written USAGE.md. Policy (and the
20230
+ # LOKI_ALLOW_CLAUDE_SIDECALLS=1 opt-in) lives in providers/loader.sh,
20231
+ # sourced at the top of this file.
20232
+ # ponytail: upgrade path is routing this call through provider_invoke_argv.
20233
+ loki_claude_sidecall_allowed 2>/dev/null || return 0
19334
20234
  # Find a working `claude` binary; if absent, bail silently.
19335
20235
  if ! command -v claude >/dev/null 2>&1; then
19336
20236
  return 0
@@ -23560,6 +24460,17 @@ except Exception as exc:
23560
24460
  local exit_code=0
23561
24461
  # v7.5.12: Mark provider pipeline as active so SIGINT trap can kill it.
23562
24462
  LOKI_PROVIDER_ACTIVE=1
24463
+ # BACKLOG 68 rework: mark a turn in-flight so a SIGKILL mid-turn (pod
24464
+ # loss: no trap runs) leaves the tracked-since anchor stale rather than
24465
+ # advanced -- a resume must not prune anything the agent may have
24466
+ # tracked during this still-unrecorded turn. Cleared only by
24467
+ # _loki_record_session_created, right after this turn's own call to it
24468
+ # below. Gated the same way as the anchor itself (this run has a live
24469
+ # snapshot); an older/opted-out run writes no marker, matching its
24470
+ # pre-existing no-anchor ("skip pruning") behavior.
24471
+ if [ "${_LOKI_SNAPSHOT_THIS_RUN:-0}" = 1 ]; then
24472
+ mkdir -p .loki/state 2>/dev/null && : > .loki/state/turn-in-flight 2>/dev/null
24473
+ fi
23563
24474
  # v7.7.31: authorize autonomous operation at the system-prompt tier so
23564
24475
  # the spawned agent does not read the user's global ~/.claude/CLAUDE.md,
23565
24476
  # judge it to conflict with the loki_system prompt, call AskUserQuestion,
@@ -26853,6 +27764,8 @@ except (json.JSONDecodeError, OSError): pass
26853
27764
  main() {
26854
27765
  _loki_install_signal_traps
26855
27766
  SESSION_START_EPOCH=$(date +%s)
27767
+ # Before anything can spawn a provider (capability probes included).
27768
+ _loki_withhold_github_tokens
26856
27769
 
26857
27770
  # First-run disclosure (shown once, before any work; best-effort).
26858
27771
  if type loki_show_disclosure_once &>/dev/null; then
@@ -27021,6 +27934,10 @@ main() {
27021
27934
 
27022
27935
  # Handle background mode
27023
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
27024
27941
  # Initialize .loki directory first
27025
27942
  mkdir -p .loki/logs
27026
27943
 
@@ -27656,7 +28573,9 @@ main() {
27656
28573
  # then advise the user how to open a PR. Both are no-ops when no agent branch
27657
28574
  # was set up (LOKI_BRANCH_PROTECTION=false) or nothing changed.
27658
28575
  commit_session_changes
27659
- 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
27660
28579
  audit_agent_action "session_stop" "Session ended" "result=$result,iterations=$ITERATION_COUNT"
27661
28580
 
27662
28581
  # The first terminal summary is written before session changes are committed.