loki-mode 9.54.2 → 9.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/autonomy/run.sh CHANGED
@@ -9180,6 +9180,12 @@ setup_agent_branch() {
9180
9180
  log_warn "Left uncommitted from an earlier unfinished session, now treated as your files: $leftover"
9181
9181
  fi
9182
9182
  rm -f .loki/state/session-created.z 2>/dev/null
9183
+ # BACKLOG 68 rework: a fresh mint starts with no tracked-since anchor and no
9184
+ # in-flight marker (never seed the anchor from session-start-sha -- an
9185
+ # anchor==start would let a later resume's "anchor..HEAD" diff include this
9186
+ # session's OWN commits, pruning them from the next session's union as if
9187
+ # the user had tracked them between sessions).
9188
+ rm -f .loki/state/tracked-since.sha .loki/state/turn-in-flight 2>/dev/null
9183
9189
  if [ "$snap_ok" = 1 ]; then _LOKI_SNAPSHOT_THIS_RUN=1; fi
9184
9190
 
9185
9191
  # Every mint (a refused resume included) records its own base and the
@@ -9213,6 +9219,352 @@ _loki_snapshot_or_fail_closed() {
9213
9219
  return 1
9214
9220
  }
9215
9221
 
9222
+ # BACKLOG 70: preexisting-untracked.z and its .sha.z sibling live in
9223
+ # .loki/state, which the agent process (and anything it spawns) can write. A
9224
+ # co-located adversarial agent can delete .sha.z to blind
9225
+ # workspace_diff._preexisting_untracked's mismatch check (silently suppressing
9226
+ # every preexisting_modified disclosure), or append/edit .z entries to hide its
9227
+ # own new files from the receipt or falsely disown them. Full prevention against
9228
+ # a same-UID process is not achievable (it can read and rewrite anything this
9229
+ # process can); the realistic bar, and the one BACKLOG 70 asks for, is
9230
+ # DISCLOSURE: this process is the only one that can remember what it itself
9231
+ # just wrote, so it seals a sha256 of both files in its own memory right after
9232
+ # writing them, and re-derives + compares that digest at every later read site.
9233
+ # A mismatch (or the sealed file now missing) is reported as tampering and
9234
+ # treated as a failed snapshot (fail closed: no session commit), never patched
9235
+ # over silently. Cleared whenever the files are deliberately removed (line
9236
+ # ~9165) so that path is never reported as tampering.
9237
+ _LOKI_SNAPSHOT_SEAL=""
9238
+
9239
+ # BACKLOG 129 (follow-up to BACKLOG 70/BACKLOG 70-awk): the DIGEST computation
9240
+ # above is hardened (fixed-root-path-first, no awk), but until this function
9241
+ # was added the snapshot WRITER (_loki_untracked_merge) and READER
9242
+ # (_loki_covered_paths), plus the hash-snapshot helper that produces .sha.z,
9243
+ # still ran bare `python3 -E`. -E only blocks PYTHON* env vars; it does NOT
9244
+ # stop user (or Homebrew) site-packages from loading, and Python execs every
9245
+ # line starting "import " in every .pth file found there on every startup
9246
+ # (confirmed empirically: a .pth planted in user site-packages fired and
9247
+ # silently rewrote the writer's declared output file after the legitimate
9248
+ # write completed). A same-UID agent that plants such a .pth can forge the
9249
+ # CONTENT of the snapshot BEFORE the (correctly hardened) seal ever computes a
9250
+ # digest over it -- the seal then faithfully approves the forgery. Resolved
9251
+ # the same way as _loki_snapshot_hash_tool: fixed root-owned candidate paths
9252
+ # first (a materially higher bar than a user-writable site-packages
9253
+ # directory), each PROBED with a real run (not just `-x`, which a Command Line
9254
+ # Tools-less Mac's /usr/bin shims would still pass while failing to execute
9255
+ # and possibly popping an install dialog), then a PATH walk restricted to
9256
+ # absolute entries as a last resort. -I -S (isolated mode, and -S also skips
9257
+ # `import site` entirely) is used at every call site below: -I alone still
9258
+ # loads site-packages (verified: `python3 -I` still lists Homebrew's
9259
+ # site-packages on this host), so -S is required to close the .pth vector,
9260
+ # not just narrow it. Accepted gap, same shape as _loki_snapshot_hash_tool's:
9261
+ # replacing the interpreter binary AT ONE OF THE FIXED PATHS this resolves to
9262
+ # first requires root; a host with none of the fixed candidates falls through
9263
+ # to the PATH-walk, which still trusts whatever sits first on an absolute PATH
9264
+ # entry under that name. A further, host-specific gap: on macOS these fixed
9265
+ # paths are typically Command Line Tools / Xcode shims that dispatch into
9266
+ # /Applications/Xcode.app -- root-owned on a normal install, but the fixed
9267
+ # path is only as trustworthy as that dispatch target on any host where it is
9268
+ # not.
9269
+ _loki_snapshot_py_tool() {
9270
+ local c
9271
+ for c in /usr/bin/python3 /bin/python3; do
9272
+ [ -x "$c" ] && [ ! -d "$c" ] && "$c" -I -S -c '' >/dev/null 2>&1 && { printf '%s\n' "$c"; return 0; }
9273
+ done
9274
+ local dir
9275
+ local IFS=:
9276
+ for dir in $PATH; do
9277
+ case "$dir" in
9278
+ /*) ;;
9279
+ *) continue ;;
9280
+ esac
9281
+ if [ -x "$dir/python3" ] && [ ! -d "$dir/python3" ] \
9282
+ && "$dir/python3" -I -S -c '' >/dev/null 2>&1; then
9283
+ printf '%s\n' "$dir/python3"
9284
+ return 0
9285
+ fi
9286
+ done
9287
+ return 1
9288
+ }
9289
+
9290
+ # BACKLOG 129: same rationale and pattern as _loki_snapshot_py_tool above, for
9291
+ # `git`. Every call in this snapshot writer/reader chain
9292
+ # (_loki_untracked_status, _loki_tracked_by_user_since_anchor,
9293
+ # _loki_snapshot_preexisting's own `git rev-parse --show-toplevel`) used a bare
9294
+ # `git` -- an unscoped PATH lookup a same-UID attacker can shadow with a fake
9295
+ # binary placed in a user-writable directory ahead of the real one, exactly
9296
+ # the class of bypass _loki_snapshot_hash_tool's own comment already documents
9297
+ # for sha256sum/shasum. Reproduced empirically: a fake `git` ahead on PATH
9298
+ # that filters one path out of `git status`'s output caused that path to be
9299
+ # completely absent from the sealed snapshot. `_loki_untrack_agent_committed_
9300
+ # user_files`'s git calls (feeding the READER path, _loki_covered_paths) and
9301
+ # the commit-time `git reset`/`git commit`/`git update-index` calls in
9302
+ # commit_session_changes are DELIBERATELY left unresolved here (out of scope
9303
+ # for this fix): a fake git on PATH already defeats the commit gate directly
9304
+ # at commit time regardless of what the snapshot says, so resolving only the
9305
+ # reader's git feed would not change that attacker's outcome. Tracked as a
9306
+ # follow-up (process-wide resolved git) rather than folded into this change.
9307
+ _loki_snapshot_git_tool() {
9308
+ local c
9309
+ for c in /usr/bin/git /bin/git; do
9310
+ [ -x "$c" ] && [ ! -d "$c" ] && "$c" --version >/dev/null 2>&1 && { printf '%s\n' "$c"; return 0; }
9311
+ done
9312
+ local dir
9313
+ local IFS=:
9314
+ for dir in $PATH; do
9315
+ case "$dir" in
9316
+ /*) ;;
9317
+ *) continue ;;
9318
+ esac
9319
+ if [ -x "$dir/git" ] && [ ! -d "$dir/git" ] && "$dir/git" --version >/dev/null 2>&1; then
9320
+ printf '%s\n' "$dir/git"
9321
+ return 0
9322
+ fi
9323
+ done
9324
+ return 1
9325
+ }
9326
+
9327
+ # _loki_snapshot_hash_tool: resolve an absolute path to sha256sum or shasum.
9328
+ # Fixed, root-owned candidate paths are tried FIRST, before any PATH walk: a
9329
+ # same-UID agent that can write to a user-writable directory ahead of the real
9330
+ # tool on PATH (Homebrew's prefix, e.g. /opt/homebrew/bin, is user-writable by
9331
+ # default on macOS and typically precedes /usr/bin) could otherwise drop its
9332
+ # own sha256sum/shasum there that reads the two snapshot files and echoes back
9333
+ # whatever hash it wants -- the same class of bypass as the .pth hijack this
9334
+ # whole function exists to close, just with a fake binary as the carrier
9335
+ # instead of a fake module. Trying the standard system locations first closes
9336
+ # that: they require root to write to, a materially higher bar than a
9337
+ # user-writable site-packages directory or a user-writable PATH entry.
9338
+ # A PATH walk (D7: never trust a bare command name -- restricted to absolute
9339
+ # entries, matching autonomy/loki's loki_verify_tool_path) is kept only as a
9340
+ # last resort for a host that keeps sha256sum/shasum somewhere nonstandard;
9341
+ # document there that this fallback still trusts whatever is first on an
9342
+ # absolute PATH entry, which a user-writable directory ahead of the system
9343
+ # dirs can still shadow. Prints nothing (caller then falls back) when no tool
9344
+ # is found anywhere.
9345
+ _loki_snapshot_hash_tool() {
9346
+ local c
9347
+ for c in /usr/bin/sha256sum /bin/sha256sum /sbin/sha256sum \
9348
+ /usr/bin/shasum /bin/shasum; do
9349
+ [ -x "$c" ] && [ ! -d "$c" ] && { printf '%s\n' "$c"; return 0; }
9350
+ done
9351
+ # Last resort: PATH walk, absolute entries only. Still trusts whatever
9352
+ # sits first on PATH under that name; see comment above.
9353
+ local name dir
9354
+ local IFS=:
9355
+ for name in sha256sum shasum; do
9356
+ for dir in $PATH; do
9357
+ case "$dir" in
9358
+ /*) ;;
9359
+ *) continue ;;
9360
+ esac
9361
+ if [ -x "$dir/$name" ] && [ ! -d "$dir/$name" ]; then
9362
+ printf '%s\n' "$dir/$name"
9363
+ return 0
9364
+ fi
9365
+ done
9366
+ done
9367
+ return 1
9368
+ }
9369
+
9370
+ # _loki_snapshot_digest: sha256 of preexisting-untracked.z + .sha.z content
9371
+ # (each independently, "-" standing in for "file absent" so a delete is part
9372
+ # of the digest, not indistinguishable from an empty file; "?" standing in for
9373
+ # "file present but the hash tool failed to produce a real digest" -- a
9374
+ # nonzero exit, empty output, or output that fails validation below -- so a
9375
+ # transient read error never seals or compares as an empty string).
9376
+ #
9377
+ # HIGH-severity reviewer finding on the original python3 -E version: -E only
9378
+ # ignores PYTHON* env vars, it does NOT stop Python from loading user (or, on
9379
+ # macOS with Homebrew, system) site-packages, and Python executes every .pth
9380
+ # file found there on every startup. A same-UID agent process can plant a .pth
9381
+ # file there that hijacks this script's own print() to emit a cached, stale
9382
+ # (pre-tamper) digest -- fully defeating the seal while `git`/file state show
9383
+ # real tampering happened. Fixed by computing the digest with sha256sum/shasum
9384
+ # instead: neither has a .pth-style module-loading path for that hijack to
9385
+ # attach to. shasum is a Perl script on macOS (not a compiled binary), but its
9386
+ # @INC search path is root-owned system Perl library directories, not a user
9387
+ # site-packages directory, so the .pth carrier does not transfer to it.
9388
+ #
9389
+ # SECOND reviewer finding, on the shasum/sha256sum version that first closed
9390
+ # the .pth hole: the digest was piped through a bare `awk '{print $1}')`
9391
+ # resolved via an unscoped PATH lookup (`awk '{...}'` runs whatever `awk`
9392
+ # comes first on PATH). A fake `awk` placed in a user-writable directory ahead
9393
+ # of the real one (the same Homebrew-prefix-precedes-/usr/bin shape as the
9394
+ # hash-tool bypass above) can intercept the pipe and print a cached, stale
9395
+ # digest, defeating the seal exactly as before -- the fix reintroduced the
9396
+ # same bypass class it was written to close, just moved one command over.
9397
+ # Fixed by dropping awk entirely: the first whitespace-delimited field is
9398
+ # taken with the shell builtin `${h%% *}` (parameter expansion, no external
9399
+ # process, nothing on PATH to hijack), and the result is validated as exactly
9400
+ # 64 lowercase hex characters before being trusted as a real sha256 digest --
9401
+ # anything else (empty, wrong length, non-hex, a hand-rolled fake tool that
9402
+ # does not bother to format like sha256sum/shasum) becomes "?". The hash
9403
+ # tool's own exit status is also checked explicitly now: a piped
9404
+ # `cmd | awk ...` hid a nonzero exit from the hash tool behind awk's exit
9405
+ # status; capturing to a plain variable surfaces it.
9406
+ #
9407
+ # Resolved via _loki_snapshot_hash_tool above (fixed root-owned paths first,
9408
+ # PATH walk restricted to absolute entries only as a last resort), not a bare
9409
+ # `command -v`, so this also closes the sibling D7-pattern gap: an unscoped
9410
+ # lookup could otherwise resolve to a same-named binary planted in a
9411
+ # user-writable directory ahead of the real tool on PATH.
9412
+ #
9413
+ # Accepted gaps (disclosure, not prevention, per BACKLOG 70's own bar, same as
9414
+ # documented at _LOKI_SNAPSHOT_SEAL above): a same-UID ptrace attack on this
9415
+ # process or on the sha256sum/shasum child process itself; replacing the
9416
+ # sha256sum/shasum binary AT ONE OF THE FIXED SYSTEM PATHS this resolves to
9417
+ # first (requires root, a materially higher bar than a user-writable
9418
+ # site-packages .pth or a user-writable PATH entry); on a host with none of
9419
+ # the fixed candidates, the PATH-walk fallback inside _loki_snapshot_hash_tool
9420
+ # still trusts whatever sits first on an absolute PATH entry under that name,
9421
+ # which a user-writable directory ahead of the system dirs (e.g. Homebrew's
9422
+ # prefix on macOS) can shadow -- not fully closed, only narrowed to hosts
9423
+ # 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.
9429
+ _loki_snapshot_digest() {
9430
+ local tool
9431
+ tool="$(_loki_snapshot_hash_tool)" || tool=""
9432
+ if [ -n "$tool" ]; then
9433
+ local out="" path h
9434
+ for path in ".loki/state/preexisting-untracked.z" ".loki/state/preexisting-untracked.sha.z"; do
9435
+ if [ -f "$path" ]; then
9436
+ h=""
9437
+ case "${tool##*/}" in
9438
+ shasum) h="$("$tool" -a 256 -- "$path" 2>/dev/null)" || h="" ;;
9439
+ *) h="$("$tool" -- "$path" 2>/dev/null)" || h="" ;;
9440
+ esac
9441
+ # No awk: take the first whitespace-delimited field with a
9442
+ # shell builtin (nothing on PATH to hijack), then require
9443
+ # exactly 64 lowercase hex characters. Anything else -- empty
9444
+ # output, a nonzero exit already caught above, an unexpected
9445
+ # format, a hand-rolled fake tool -- becomes "?" rather than
9446
+ # being trusted as a real digest. "?" is distinct from "-"
9447
+ # (file absent) and from any real hex digest, so a later
9448
+ # successful read of the same file mismatches it and is still
9449
+ # disclosed as tampering.
9450
+ h="${h%% *}"
9451
+ if [ "${#h}" -eq 64 ]; then
9452
+ case "$h" in
9453
+ *[!0123456789abcdef]*) h="?" ;;
9454
+ esac
9455
+ else
9456
+ h="?"
9457
+ fi
9458
+ out="$out $h"
9459
+ else
9460
+ out="$out -"
9461
+ fi
9462
+ done
9463
+ printf '%s\n' "${out# }"
9464
+ return 0
9465
+ fi
9466
+ # 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
9471
+ # accepted gap specific to this fallback (not present on the tool-based
9472
+ # path above, which distinguishes "-" from "?"): OSError covers BOTH
9473
+ # "file does not exist" and "file exists but is unreadable" here, so an
9474
+ # unreadable file on this path is indistinguishable from an absent one --
9475
+ # both print "-". The tool-based path above tells them apart ("-" for
9476
+ # absent, "?" for present-but-unreadable, caught by the seal-time
9477
+ # fail-closed check). Narrowing this further (os.path.lexists to emit "?"
9478
+ # for present-but-unreadable) is straightforward but left undone here: this
9479
+ # fallback only runs on a host with neither sha256sum nor shasum anywhere,
9480
+ # already the rare case this whole function treats as a residual gap.
9481
+ python3 -I -c 'import sys, hashlib, os
9482
+ sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9483
+ out = []
9484
+ for path in sys.argv[1:]:
9485
+ try:
9486
+ with open(path, "rb") as fh:
9487
+ out.append(hashlib.sha256(fh.read()).hexdigest())
9488
+ except OSError:
9489
+ out.append("-")
9490
+ print(" ".join(out))' \
9491
+ ".loki/state/preexisting-untracked.z" ".loki/state/preexisting-untracked.sha.z" 2>/dev/null
9492
+ }
9493
+
9494
+ # _loki_snapshot_seal: record this process's own memory of what it just wrote.
9495
+ # Called only right after a successful _loki_snapshot_preexisting. Minor
9496
+ # reviewer note: a legitimate snapshot write should never hit an unreadable-
9497
+ # file condition immediately after writing it, so a "?" in the digest AT SEAL
9498
+ # TIME (as opposed to at a later verify) is itself suspicious -- both seal and
9499
+ # a later verify could then read "?", compare equal, and silently disarm the
9500
+ # guard for the rest of the session (the same failure mode
9501
+ # _loki_snapshot_verify's mismatch path exists to catch, just never triggered
9502
+ # because both sides agree on the sentinel). Closed cheaply: reject an empty
9503
+ # or "?"-containing digest at seal time the same way _loki_snapshot_verify
9504
+ # rejects a mismatch -- log and disclose it, write the .failed marker, AND
9505
+ # clear _LOKI_SNAPSHOT_THIS_RUN directly here (belt-and-braces: the actual
9506
+ # gate that stops a commit). The call site right after `_loki_snapshot_seal`
9507
+ # in _loki_snapshot_preexisting also checks `_LOKI_SNAPSHOT_SEAL` and returns
9508
+ # non-zero on failure, which is what actually propagates to
9509
+ # _loki_snapshot_or_fail_closed / _loki_resume_snapshot and keeps their
9510
+ # existing callers from ever setting _LOKI_SNAPSHOT_THIS_RUN=1 in the first
9511
+ # place (see the comment there) -- so the in-function clear here is redundant
9512
+ # with that, not the sole mechanism, kept only as defense in depth in case a
9513
+ # future caller invokes this function directly. The call site above is
9514
+ # intentionally left as a bare `_loki_snapshot_seal` (no `|| return`):
9515
+ # tests/test-branch-lifecycle.sh's RED harness matches that exact line to
9516
+ # strip the guard; the propagation check right after it carries its own
9517
+ # trailing-comment marker the RED harness also strips (see the comment at
9518
+ # that check, in _loki_snapshot_preexisting, for the exact text -- not
9519
+ # repeated here so a grep for it does not also match this sentence), so
9520
+ # removing either half of the guard is enough to reproduce the pre-fix
9521
+ # silent-degrade behavior in that test.
9522
+ _loki_snapshot_seal() {
9523
+ local d
9524
+ d="$(_loki_snapshot_digest)"
9525
+ case "$d" in
9526
+ ""|*'?'*)
9527
+ _LOKI_SNAPSHOT_SEAL=""
9528
+ _LOKI_SNAPSHOT_THIS_RUN=0
9529
+ 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)"
9530
+ audit_log "SNAPSHOT_SEAL_FAILED" "_loki_snapshot_digest returned empty or an unreadable-file sentinel at seal time"
9531
+ mkdir -p .loki/state 2>/dev/null
9532
+ : > .loki/state/preexisting-untracked.failed 2>/dev/null
9533
+ ;;
9534
+ *)
9535
+ _LOKI_SNAPSHOT_SEAL="$d"
9536
+ ;;
9537
+ esac
9538
+ }
9539
+
9540
+ # _loki_snapshot_verify: compare the live files against the sealed digest.
9541
+ # No seal recorded yet (never snapshotted this process, or deliberately
9542
+ # cleared) is not tampering: returns success and does nothing. A seal that no
9543
+ # longer matches IS tampering: logs it (disclosure is the bar BACKLOG 70 asks
9544
+ # for; a same-UID process can rewrite anything else this one could check), and
9545
+ # forces every fail-closed path a missing/failed snapshot already takes --
9546
+ # clears the seal so a caller cannot re-check a stale value, clears
9547
+ # _LOKI_SNAPSHOT_THIS_RUN so commit_session_changes and
9548
+ # _loki_record_session_created both treat this as an unsnapshotted session, and
9549
+ # (re)writes preexisting-untracked.failed so BACKLOG 90's existing message
9550
+ # fires. Every consumer of the snapshot/hash files calls this first and treats
9551
+ # non-zero as "no snapshot".
9552
+ _loki_snapshot_verify() {
9553
+ local live=""
9554
+ [ -n "$_LOKI_SNAPSHOT_SEAL" ] || return 0
9555
+ live="$(_loki_snapshot_digest)"
9556
+ if [ "$live" = "$_LOKI_SNAPSHOT_SEAL" ]; then
9557
+ return 0
9558
+ fi
9559
+ _LOKI_SNAPSHOT_SEAL=""
9560
+ _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
+ mkdir -p .loki/state 2>/dev/null
9564
+ : > .loki/state/preexisting-untracked.failed 2>/dev/null
9565
+ return 1
9566
+ }
9567
+
9216
9568
  # _loki_snapshot_preexisting [union]
9217
9569
  # Write .loki/state/preexisting-untracked.z: every path git does not track,
9218
9570
  # untracked and gitignored, repo-wide, NUL-delimited, relative to the repo top.
@@ -9221,33 +9573,90 @@ _loki_snapshot_or_fail_closed() {
9221
9573
  # a directory only when an ignore pattern matches it, so node_modules/ is one
9222
9574
  # "dir/" entry covering its subtree, while logs/ holding only *.log files is
9223
9575
  # 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.
9576
+ # the paths already recorded, minus any that became tracked because the USER
9577
+ # committed them between sessions (BACKLOG 68 rework). Distinguishing that from
9578
+ # "the AGENT itself committed or staged this pre-existing file during THIS
9579
+ # session, then got killed" is the entire point of the anchor below: only a
9580
+ # path git ADDED strictly after the last point this run itself could reach is
9581
+ # ever pruned. A path the agent tracked before that point is left IN the union
9582
+ # so it still flows into _loki_untrack_agent_committed_user_files's disclosure
9583
+ # (warn, remove from the branch tip, keep on disk) -- never silently swept in.
9584
+ #
9585
+ # The anchor is .loki/state/tracked-since.sha, a commit SHA written by
9586
+ # _loki_record_session_created (after each provider turn) and by
9587
+ # commit_session_changes (at session end): the newest point this run itself
9588
+ # recorded. .loki/state/turn-in-flight marks a provider turn as currently
9589
+ # running (set right before invocation, cleared by the same record call that
9590
+ # advances the anchor); its presence means a turn may have committed something
9591
+ # since the last anchor write, so pruning is skipped entirely rather than risk
9592
+ # treating an agent's mid-turn commit as the user's. Any anchor-resolution
9593
+ # failure (missing anchor, anchor not an ancestor of HEAD, merge-base failure)
9594
+ # also skips pruning: the safe direction is the old behavior (false blame, file
9595
+ # kept on disk), never silent adoption.
9596
+ #
9597
+ # The sibling .sha.z (content hash per file entry) lets the receipt list a
9598
+ # pre-existing file the run changed; it is removed first, so a failed hash step
9599
+ # means no detection, never stale hashes. Returns non-zero, leaving any earlier
9600
+ # list in place, when git cannot list. Seals a digest of both files on success
9601
+ # (BACKLOG 70) so later reads can detect tampering.
9228
9602
  _loki_snapshot_preexisting() {
9229
- local snap=".loki/state/preexisting-untracked.z" top="" base="" exclude=""
9603
+ local snap=".loki/state/preexisting-untracked.z" top="" base="" exclude="" tracked="" gittool="" pytool=""
9230
9604
  rm -f "${snap%.z}.sha.z" 2>/dev/null
9231
- top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
9605
+ gittool="$(_loki_snapshot_git_tool)" || return 1
9606
+ top="$("$gittool" rev-parse --show-toplevel 2>/dev/null)" || return 1
9232
9607
  mkdir -p .loki/state 2>/dev/null || return 1
9233
9608
  # "union" never adds a path listed in session-created.z: those files were
9234
9609
  # made by a session that did not finish (interrupt, pod loss), so the
9235
9610
  # session that finishes the work commits them. If the user edits one of
9236
9611
  # 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.
9612
+ # with the edit. Recorded entries only block additions; a path this run can
9613
+ # prove the USER tracked between sessions is the one case they DO remove
9614
+ # (see _loki_tracked_by_user_since_anchor below).
9239
9615
  if [ "${1:-}" = union ]; then
9240
9616
  base="$snap"
9241
9617
  exclude=".loki/state/session-created.z"
9618
+ tracked="$snap.tracked"
9619
+ if ! _loki_tracked_by_user_since_anchor "$top" "$tracked"; then
9620
+ rm -f "$tracked"
9621
+ tracked=""
9622
+ fi
9242
9623
  fi
9243
9624
  if ! _loki_untracked_status "$snap.status" \
9244
- || ! _loki_untracked_merge "$snap.status" "$base" "$exclude" exact "$snap"; then
9245
- rm -f "$snap.status" "$snap.tmp"
9625
+ || ! _loki_untracked_merge "$snap.status" "$base" "$exclude" exact "$snap" "$tracked"; then
9626
+ rm -f "$tracked" "$snap.status" "$snap.tmp"
9246
9627
  return 1
9247
9628
  fi
9248
- rm -f "$snap.status"
9249
- python3 -E "$SCRIPT_DIR/lib/workspace_diff.py" hash-snapshot "$top" "$snap" >/dev/null 2>&1 \
9629
+ rm -f "$tracked" "$snap.status"
9630
+ # BACKLOG 129: write_snapshot_hashes (the only code path "hash-snapshot"
9631
+ # reaches) does pure file I/O (open/os.path/hashlib), no sibling import
9632
+ # from autonomy/lib and no subprocess/git call, so -I -S is safe here: it
9633
+ # never needs the script's own directory on sys.path or anything from
9634
+ # site-packages. This writes .sha.z, one of the two files the seal above
9635
+ # digests, so it is in scope of the same forgery class as the merge/covered
9636
+ # sites below. A failed resolve here degrades to the existing log_warn path
9637
+ # (receipt loses preexisting_modified detection) rather than blocking the
9638
+ # snapshot -- matching this call's pre-existing non-fatal-on-failure shape.
9639
+ pytool="$(_loki_snapshot_py_tool)" && "$pytool" -I -S "$SCRIPT_DIR/lib/workspace_diff.py" hash-snapshot "$top" "$snap" >/dev/null 2>&1 \
9250
9640
  || log_warn "Could not hash your pre-existing untracked files; the receipt cannot list the ones this run changes"
9641
+ _loki_snapshot_seal
9642
+ # A seal failure (empty digest or an unreadable-file "?", see the comment
9643
+ # at _loki_snapshot_seal) leaves _LOKI_SNAPSHOT_SEAL empty; propagate that
9644
+ # as a failure of this function too, so both callers'
9645
+ # (_loki_snapshot_or_fail_closed and _loki_resume_snapshot) existing
9646
+ # fail-closed handling applies, instead of silently reporting success on a
9647
+ # session this process could not actually seal. This is the line that
9648
+ # actually makes a seal failure fail closed (_loki_snapshot_seal's own
9649
+ # in-function clear is defense in depth, not the mechanism -- see the
9650
+ # comment there). This line is deliberately also stripped by
9651
+ # tests/test-branch-lifecycle.sh's BACKLOG-70 RED mutation, by its trailing
9652
+ # comment marker (kept off this sentence on purpose, so a grep for the
9653
+ # marker text matches only the one real code line below, not this prose),
9654
+ # same as the guard calls above it: without it, on a mutated copy that
9655
+ # also strips `_loki_snapshot_seal`, _LOKI_SNAPSHOT_SEAL stays at its
9656
+ # global initial value ("") and this check alone would still return 1 and
9657
+ # fail closed, masking the RED reproduction the test relies on to prove
9658
+ # the mutation of the ACTUAL guard calls is non-vacuous.
9659
+ [ -n "$_LOKI_SNAPSHOT_SEAL" ] || return 1 # BACKLOG-70-SEAL-CHECK
9251
9660
  return 0
9252
9661
  }
9253
9662
 
@@ -9255,32 +9664,57 @@ _loki_snapshot_preexisting() {
9255
9664
  # and the session-created record, so the two always agree: raw
9256
9665
  # `git status --porcelain -z` records for the whole repo, .loki excluded.
9257
9666
  # Non-zero, writing nothing, when git cannot list.
9667
+ # BACKLOG 129: resolved via _loki_snapshot_git_tool (fixed root-owned path
9668
+ # first, PATH walk restricted to absolute entries as a last resort), not a
9669
+ # bare `git`, which a same-UID attacker can shadow with a fake binary on a
9670
+ # user-writable PATH entry ahead of the real one -- reproduced empirically: a
9671
+ # fake git filtering one path out of its `status` output made that path
9672
+ # entirely absent from the sealed snapshot this function ultimately feeds.
9258
9673
  _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 \
9674
+ local top="" prefix="" gittool=""
9675
+ gittool="$(_loki_snapshot_git_tool)" || return 1
9676
+ top="$("$gittool" rev-parse --show-toplevel 2>/dev/null)" || return 1
9677
+ prefix="$("$gittool" rev-parse --show-prefix 2>/dev/null)" || return 1
9678
+ "$gittool" -C "$top" --no-optional-locks status --porcelain -z --no-renames -uall \
9263
9679
  --ignored=matching --ignore-submodules=all -- ":(exclude,literal)${prefix}.loki" \
9264
9680
  > "$1" 2>/dev/null && return 0
9265
9681
  rm -f "$1"
9266
9682
  return 1
9267
9683
  }
9268
9684
 
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.
9685
+ # _loki_untracked_merge <status> <base> <exclude> exact|cover <out> [tracked]
9686
+ # Atomically write <out>: the NUL-delimited paths of <base> minus any path
9687
+ # <tracked> lists (BACKLOG 68: entries the USER tracked between sessions, as
9688
+ # resolved by _loki_tracked_by_user_since_anchor -- never the whole index),
9689
+ # plus every untracked ("??") or ignored ("!!") path in <status> that <exclude>
9690
+ # does not hold. With "cover", an <exclude> entry ending in "/" also holds
9691
+ # every path below it (workspace_diff._covered). An empty or missing <base> or
9692
+ # <exclude> is an empty list; any other read or write failure is non-zero and
9693
+ # leaves <out> as it was. Sorted bytewise, like `LC_ALL=C sort -z -u`. One
9694
+ # python process: bash 3.2 has no associative arrays, and a bash loop cost
9695
+ # 300ms per 6,000 entries on /bin/bash. -I -S and no cwd on sys.path (D7): the
9696
+ # cwd is the agent's repo.
9697
+ # BACKLOG 129: this is the snapshot WRITER -- its <out> becomes
9698
+ # preexisting-untracked.z, one of the two files the seal above digests. A bare
9699
+ # `python3 -E` here still loads user site-packages (-E only blocks PYTHON* env
9700
+ # vars), so a same-UID .pth planted there can run arbitrary code during this
9701
+ # call and rewrite <out> after the legitimate write, forging exactly the bytes
9702
+ # the seal is about to trust -- reproduced empirically (a .pth that stripped
9703
+ # an entry from the just-written output). Resolved via _loki_snapshot_py_tool
9704
+ # (fixed root-owned path first) and run with -I -S: -I alone is not enough
9705
+ # (verified: `python3 -I` alone still lists Homebrew's user-writable
9706
+ # site-packages on this host), -S additionally skips `import site` so no .pth
9707
+ # anywhere is ever processed. A resolve failure fails this function closed
9708
+ # (return 1, same as any other read/write failure here) rather than silently
9709
+ # falling back to a bare, unresolved `python3`.
9279
9710
  _loki_untracked_merge() {
9280
- python3 -E -c 'import sys
9711
+ local pytool=""
9712
+ pytool="$(_loki_snapshot_py_tool)" || return 1
9713
+ "$pytool" -I -S -c 'import sys
9281
9714
  sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9282
9715
  import os
9283
9716
  status, base, exclude, mode, out = sys.argv[1:6]
9717
+ tracked = sys.argv[6] if len(sys.argv) > 6 else ""
9284
9718
  def entries(path, missing_ok=True):
9285
9719
  try:
9286
9720
  with open(path, "rb") as fh:
@@ -9300,6 +9734,11 @@ def covered(path):
9300
9734
  cut = path.find(b"/", cut + 1)
9301
9735
  return False
9302
9736
  paths = set(entries(base)) if base else set()
9737
+ if tracked:
9738
+ # Exact match only: a "dir/" entry is never dropped even if a file under it
9739
+ # is now tracked, so an ignored directory (node_modules/) never re-exposes
9740
+ # the rest of its subtree (BACKLOG 66/78 territory).
9741
+ paths -= set(entries(tracked))
9303
9742
  for rec in entries(status, missing_ok=False):
9304
9743
  # The session-created record ("cover" mode) never holds a directory entry:
9305
9744
  # a whole-directory entry (logs/, out/) would stop the resume union from
@@ -9315,6 +9754,80 @@ with open(out + ".tmp", "wb") as fh:
9315
9754
  os.replace(out + ".tmp", out)' "$@" 2>/dev/null
9316
9755
  }
9317
9756
 
9757
+ # _loki_tracked_by_user_since_anchor <top> <out> (BACKLOG 68 rework)
9758
+ # Write <out>: the NUL-delimited paths git added to the index between the
9759
+ # recorded "tracked-since" anchor and HEAD -- i.e. what a resume union may
9760
+ # safely treat as "the USER tracked this between sessions" and prune. Returns
9761
+ # non-zero (writing nothing) whenever that inference is not safe, which leaves
9762
+ # the caller pruning nothing (the old, safe-by-default behavior):
9763
+ # - no anchor recorded yet (an older session, or nothing survived a turn)
9764
+ # - a provider turn is marked in-flight (.loki/state/turn-in-flight): a turn
9765
+ # can commit or stage a file and then be SIGKILLed before the next record
9766
+ # call advances the anchor, so anything since the LAST anchor is unproven
9767
+ # - the anchor does not resolve, or is not an ancestor of HEAD (branch
9768
+ # switched, history rewritten, anchor stale)
9769
+ # --diff-filter=A, --no-renames: only additions, matching
9770
+ # _loki_untrack_agent_committed_user_files's own diff so the two never
9771
+ # disagree about which paths are "added since X".
9772
+ # BACKLOG 129: <out> here is subtracted from the snapshot base inside
9773
+ # _loki_untracked_merge (the "tracked" argument) before that WRITER seals its
9774
+ # result -- a fake git that lists an extra path here can prune a real
9775
+ # untracked path (e.g. a secret) out of the sealed snapshot, the same attack
9776
+ # as a forged `git status`, just one function over. Resolved via
9777
+ # _loki_snapshot_git_tool like the other git calls in this writer chain.
9778
+ _loki_tracked_by_user_since_anchor() {
9779
+ local top="$1" out="$2" anchor_file=".loki/state/tracked-since.sha" anchor="" gittool=""
9780
+ [ -f .loki/state/turn-in-flight ] && return 1
9781
+ [ -s "$anchor_file" ] || return 1
9782
+ anchor="$(cat "$anchor_file" 2>/dev/null)"
9783
+ [ -n "$anchor" ] || return 1
9784
+ gittool="$(_loki_snapshot_git_tool)" || return 1
9785
+ "$gittool" rev-parse --verify -q "$anchor" >/dev/null 2>&1 || return 1
9786
+ "$gittool" merge-base --is-ancestor "$anchor" HEAD 2>/dev/null || return 1
9787
+ "$gittool" -C "$top" diff --name-only -z --no-renames --diff-filter=A "$anchor" HEAD > "$out.tmp" 2>/dev/null \
9788
+ || { rm -f "$out.tmp"; return 1; }
9789
+ mv -f "$out.tmp" "$out"
9790
+ return 0
9791
+ }
9792
+
9793
+ # _loki_advance_tracked_since_anchor
9794
+ # Move .loki/state/tracked-since.sha to the current HEAD and clear
9795
+ # .loki/state/turn-in-flight, but ONLY once the anchor write is confirmed on
9796
+ # disk. Called after every provider turn (_loki_record_session_created) and at
9797
+ # normal session end (commit_session_changes), i.e. at every point this run
9798
+ # can prove no provider turn is currently running -- PROVIDED the anchor write
9799
+ # itself lands, since _loki_tracked_by_user_since_anchor trusts both signals
9800
+ # together (anchor position + in-flight marker) to prove a commit happened
9801
+ # strictly after the anchor with no turn running. Clearing the marker on a
9802
+ # failed write would decouple them: a stale anchor stays on disk while the
9803
+ # marker falsely reports "no turn was in flight", so a later resume could
9804
+ # prove something it can't actually prove and prune an agent's own commit with
9805
+ # no disclosure (this is BACKLOG 68's failure mode, reintroduced). A HEAD that
9806
+ # does not resolve (unborn branch) is not a write failure -- there is nothing
9807
+ # to anchor yet -- so the marker still clears; the anchor stays "not set" as
9808
+ # before. Always returns 0 (both call sites are bare, unchecked calls): the
9809
+ # in-flight marker, not the return code, is the safety signal on a failed
9810
+ # write.
9811
+ _loki_advance_tracked_since_anchor() {
9812
+ local sha=""
9813
+ sha="$(git rev-parse --verify -q HEAD 2>/dev/null)" || true
9814
+ if [ -n "$sha" ]; then
9815
+ if ! { printf '%s\n' "$sha" > .loki/state/tracked-since.sha.tmp 2>/dev/null \
9816
+ && mv -f .loki/state/tracked-since.sha.tmp .loki/state/tracked-since.sha 2>/dev/null; }; then
9817
+ # Anchor write failed (disk full, a stray .tmp collision, a
9818
+ # concurrent writer). Deliberately leave turn-in-flight SET and
9819
+ # return here, before the unconditional clear below: the safe
9820
+ # direction is to keep the old fallback-to-no-pruning path active
9821
+ # for one more check, never to clear a marker the anchor write did
9822
+ # not earn.
9823
+ rm -f .loki/state/tracked-since.sha.tmp 2>/dev/null
9824
+ return 0
9825
+ fi
9826
+ fi
9827
+ rm -f .loki/state/turn-in-flight 2>/dev/null
9828
+ return 0
9829
+ }
9830
+
9318
9831
  # _loki_record_session_created
9319
9832
  # Union into .loki/state/session-created.z (NUL-delimited, repo-top-relative,
9320
9833
  # atomic) the paths git does not track now that the pre-existing snapshot does
@@ -9332,6 +9845,15 @@ os.replace(out + ".tmp", out)' "$@" 2>/dev/null
9332
9845
  _loki_record_session_created() {
9333
9846
  local snap=".loki/state/preexisting-untracked.z" out=".loki/state/session-created.z"
9334
9847
  [ "${_LOKI_SNAPSHOT_THIS_RUN:-0}" = 1 ] || return 0
9848
+ # BACKLOG 70: detect tampering with the sealed snapshot as early as
9849
+ # possible (this runs after every provider turn, not just at commit time).
9850
+ _loki_snapshot_verify || return 1
9851
+ # BACKLOG 68 rework: this call is the point right after a provider turn
9852
+ # ends, so it also advances the tracked-since anchor and clears the
9853
+ # in-flight marker -- whatever the agent committed or staged during the
9854
+ # turn that just finished is now provably NOT mid-turn, so a later resume's
9855
+ # anchor..HEAD diff may safely be checked against it.
9856
+ _loki_advance_tracked_since_anchor
9335
9857
  if [ -f "$snap" ] && _loki_untracked_status "$out.status" \
9336
9858
  && _loki_untracked_merge "$out.status" "$out" "$snap" cover "$out"; then
9337
9859
  rm -f "$out.status"
@@ -9389,10 +9911,21 @@ if ! type _commit_path_looks_secret >/dev/null 2>&1; then _commit_path_looks_sec
9389
9911
  # _loki_covered_paths <paths> <snapshot> <top> <out>
9390
9912
  # Atomically write <out>: the NUL-delimited entries of <paths> that <snapshot>
9391
9913
  # 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.
9914
+ # and that still exist under <top>. Non-zero on a read or write failure. -I -S
9915
+ # and no cwd on sys.path (D7): the cwd is the agent's repo.
9916
+ # BACKLOG 129: this is the snapshot READER -- it reads <snapshot>
9917
+ # (preexisting-untracked.z, sealed content) and decides which of the agent's
9918
+ # newly-added paths are actually the user's pre-existing files (to be removed
9919
+ # from the branch tip, see _loki_untrack_agent_committed_user_files). A bare
9920
+ # `python3 -E` here has the same .pth exposure as the writer above: a same-UID
9921
+ # .pth can run arbitrary code during this call, including rewriting <out>
9922
+ # after the legitimate write. Resolved via _loki_snapshot_py_tool, run with
9923
+ # -I -S (see _loki_untracked_merge's comment for why -S, not just -I, is
9924
+ # required to close it). A resolve failure fails this function closed.
9394
9925
  _loki_covered_paths() {
9395
- python3 -E -c 'import sys
9926
+ local pytool=""
9927
+ pytool="$(_loki_snapshot_py_tool)" || return 1
9928
+ "$pytool" -I -S -c 'import sys
9396
9929
  sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9397
9930
  import os
9398
9931
  paths, snap, top, out = sys.argv[1:5]
@@ -9437,6 +9970,13 @@ _loki_untrack_agent_committed_user_files() {
9437
9970
  local snap=".loki/state/preexisting-untracked.z" rec=".loki/state/agent-committed-user-files.z"
9438
9971
  local base="" fork="" start="" top="" names=""
9439
9972
  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."
9973
+ # BACKLOG 70: a tampered snapshot cannot be trusted to say which paths were
9974
+ # pre-existing, so treat it the same as "no snapshot" -- fail closed via the
9975
+ # caller, which then skips the session commit entirely.
9976
+ if ! _loki_snapshot_verify; then
9977
+ log_warn "Could not check whether the agent committed your pre-existing untracked files on $1 (snapshot tampered). ${unchecked}"
9978
+ return 1
9979
+ fi
9440
9980
  [ -s "$snap" ] || return 0
9441
9981
  # No commit on the branch yet: the agent committed nothing.
9442
9982
  git rev-parse --verify -q HEAD >/dev/null 2>&1 || return 0
@@ -9515,6 +10055,15 @@ commit_session_changes() {
9515
10055
  ;;
9516
10056
  esac
9517
10057
 
10058
+ # BACKLOG 70: a snapshot tampered with since this session sealed it (an
10059
+ # agent process deleting the hash file to suppress preexisting_modified
10060
+ # disclosure, or editing either file to hide or falsely disown paths) is
10061
+ # treated as no snapshot at all: on a mismatch, _loki_snapshot_verify
10062
+ # clears _LOKI_SNAPSHOT_THIS_RUN and writes the same .failed marker a
10063
+ # snapshot failure writes, so the existing fail-closed check below
10064
+ # (BACKLOG 90) also catches tampering, with its own warning.
10065
+ _loki_snapshot_verify || true
10066
+
9518
10067
  # Commit nothing unless THIS run took a current snapshot (BACKLOG 90). The
9519
10068
  # LOCK A1 opt-out takes none, so it never commits, even on a leftover
9520
10069
  # loki/session-* branch whose agent-branch.txt and snapshot belong to an
@@ -9532,6 +10081,15 @@ commit_session_changes() {
9532
10081
  return 0
9533
10082
  fi
9534
10083
 
10084
+ # BACKLOG 68 rework: session end is also a point this run can prove no
10085
+ # provider turn is in flight, so advance the tracked-since anchor here too
10086
+ # (not only after each provider turn). Without this, a session that never
10087
+ # calls _loki_record_session_created between its last turn and a normal
10088
+ # end (e.g. a single-iteration run) would leave the anchor stale, and the
10089
+ # NEXT session's resume union would then have nothing to safely prune even
10090
+ # for a genuine user commit made between sessions.
10091
+ _loki_advance_tracked_since_anchor
10092
+
9535
10093
  _loki_untrack_agent_committed_user_files "$cur" || return 0
9536
10094
 
9537
10095
  # Stage everything except .loki/ runtime state and a secret-path denylist.
@@ -19331,6 +19889,12 @@ PYEOF
19331
19889
  _intelligent_usage_regen() {
19332
19890
  local target_dir="${TARGET_DIR:-.}"
19333
19891
  local usage_path="$target_dir/USAGE.md"
19892
+ # Only the provider the operator chose may see a prompt (V10 P5): on any
19893
+ # other provider keep the agent-written USAGE.md. Policy (and the
19894
+ # LOKI_ALLOW_CLAUDE_SIDECALLS=1 opt-in) lives in providers/loader.sh,
19895
+ # sourced at the top of this file.
19896
+ # ponytail: upgrade path is routing this call through provider_invoke_argv.
19897
+ loki_claude_sidecall_allowed 2>/dev/null || return 0
19334
19898
  # Find a working `claude` binary; if absent, bail silently.
19335
19899
  if ! command -v claude >/dev/null 2>&1; then
19336
19900
  return 0
@@ -23560,6 +24124,17 @@ except Exception as exc:
23560
24124
  local exit_code=0
23561
24125
  # v7.5.12: Mark provider pipeline as active so SIGINT trap can kill it.
23562
24126
  LOKI_PROVIDER_ACTIVE=1
24127
+ # BACKLOG 68 rework: mark a turn in-flight so a SIGKILL mid-turn (pod
24128
+ # loss: no trap runs) leaves the tracked-since anchor stale rather than
24129
+ # advanced -- a resume must not prune anything the agent may have
24130
+ # tracked during this still-unrecorded turn. Cleared only by
24131
+ # _loki_record_session_created, right after this turn's own call to it
24132
+ # below. Gated the same way as the anchor itself (this run has a live
24133
+ # snapshot); an older/opted-out run writes no marker, matching its
24134
+ # pre-existing no-anchor ("skip pruning") behavior.
24135
+ if [ "${_LOKI_SNAPSHOT_THIS_RUN:-0}" = 1 ]; then
24136
+ mkdir -p .loki/state 2>/dev/null && : > .loki/state/turn-in-flight 2>/dev/null
24137
+ fi
23563
24138
  # v7.7.31: authorize autonomous operation at the system-prompt tier so
23564
24139
  # the spawned agent does not read the user's global ~/.claude/CLAUDE.md,
23565
24140
  # judge it to conflict with the loki_system prompt, call AskUserQuestion,