loki-mode 9.54.0 → 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/README.md +1 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +120 -35
- package/autonomy/crash.sh +6 -6
- package/autonomy/lib/done-recognition.sh +7 -0
- package/autonomy/lib/prd-enrich.sh +7 -0
- package/autonomy/lib/proof-generator.py +36 -5
- package/autonomy/lib/proof-verify.py +93 -21
- package/autonomy/lib/voter-agents.sh +7 -0
- package/autonomy/loki +404 -119
- package/autonomy/quickstart.sh +7 -0
- package/autonomy/run.sh +836 -58
- package/autonomy/verify.sh +114 -8
- package/bin/loki +12 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_v2.py +4 -0
- package/dashboard/audit.py +9 -1
- package/dashboard/auth.py +16 -4
- package/dashboard/registry.py +34 -12
- package/dashboard/rigour_integration.py +25 -17
- package/dashboard/server.py +252 -104
- package/dashboard/static/cost.html +55 -26
- package/dashboard/static/index.html +475 -1544
- package/dashboard/static/proofs.html +1 -1
- package/docs/INSTALLATION.md +7 -6
- package/docs/air-gapped.md +38 -11
- package/docs/enterprise/security.md +10 -13
- package/docs/environment-variables.md +1 -0
- package/docs/exit-codes.md +76 -2
- package/docs/image-provenance.md +4 -2
- package/docs/v10/BACKLOG.md +64 -2
- package/docs/v10/BOARD.md +134 -0
- package/docs/v10/CONTROL.md +36 -0
- package/docs/v10/DECISIONS.md +102 -0
- package/docs/v10/METRICS.md +78 -1
- package/docs/v10/PROGRESS.md +240 -0
- package/docs/v10/SWARM-PROMPT.md +98 -0
- package/docs/v10/SWARM.md +237 -0
- package/loki-ts/dist/loki.js +7 -4
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/aider.sh +8 -0
- package/providers/cline.sh +8 -0
- package/providers/codex.sh +9 -0
- package/providers/loader.sh +10 -0
- package/providers/opencode.sh +9 -0
- package/tools/receipt-attest.py +1 -1
- package/web-app/dist/assets/AdminPage-CZrBkzRF.js +32 -0
- package/web-app/dist/assets/Avatar-gQIp6FBI.js +11 -0
- package/web-app/dist/assets/{Badge-BF3mhT57.js → Badge-D2_fBTFY.js} +1 -1
- package/web-app/dist/assets/{Button-BDp9aJ9q.js → Button-D5qDd2ef.js} +1 -1
- package/web-app/dist/assets/CockpitPage-DVMk2RUK.js +47 -0
- package/web-app/dist/assets/ComparePage-NMLyE1xL.js +1 -0
- package/web-app/dist/assets/{ErrorBoundary-ut3wrUmu.js → ErrorBoundary-BRABI-BT.js} +1 -1
- package/web-app/dist/assets/EvidenceReceiptPanel-D2LNKqUx.js +1 -0
- package/web-app/dist/assets/GitHubIssuesPanel-BC5GzKUN.js +7 -0
- package/web-app/dist/assets/{GitHubPRsPanel-DurZgSaj.js → GitHubPRsPanel-BSlmoVTS.js} +3 -3
- package/web-app/dist/assets/HomePage-D_CDMoen.js +12 -0
- package/web-app/dist/assets/{LoginPage-HeRGJeZy.js → LoginPage-BBNA_N9t.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CUKrqsZs.js → MagicPage-CnFRZCMx.js} +1 -1
- package/web-app/dist/assets/MetricsPage-CPfLMRlh.js +6 -0
- package/web-app/dist/assets/{NotFoundPage-BZDZ-B77.js → NotFoundPage-lA0XSGTz.js} +1 -1
- package/web-app/dist/assets/ProjectPage-CNAPbr9z.js +256 -0
- package/web-app/dist/assets/ProjectsPage-CMtm8goN.js +6 -0
- package/web-app/dist/assets/SettingsPage-BhIjVMYE.js +10 -0
- package/web-app/dist/assets/{ShowcasePage-BGS2jTGH.js → ShowcasePage-BndtGeYo.js} +1 -1
- package/web-app/dist/assets/SystemSettingsPage-euXI-KXE.js +1 -0
- package/web-app/dist/assets/TeamsPage-ClUKecun.js +11 -0
- package/web-app/dist/assets/TemplatesPage-BWHWVk0L.js +29 -0
- package/web-app/dist/assets/{TerminalOutput-togzv9Zl.js → TerminalOutput-Cv3N3Szf.js} +1 -1
- package/web-app/dist/assets/{activity-Dreyb_Kr.js → activity-dEhz-bXR.js} +1 -1
- package/web-app/dist/assets/{bell-DWH0drES.js → bell-B3XUMq1t.js} +1 -1
- package/web-app/dist/assets/{bot-78MoCuDl.js → bot-X-ssNlwR.js} +1 -1
- package/web-app/dist/assets/{check-ikfylPst.js → check-C_9Nx7kb.js} +1 -1
- package/web-app/dist/assets/{chevron-left-DXUqkd8J.js → chevron-left-tvNswo3A.js} +1 -1
- package/web-app/dist/assets/{circle-alert-Da74VO8P.js → circle-alert-CSB88nEs.js} +1 -1
- package/web-app/dist/assets/{clock-mlGCFq8l.js → clock-C8p2e4NI.js} +1 -1
- package/web-app/dist/assets/{cloud-B_Y5ZrIO.js → cloud-CKp2ZvhG.js} +1 -1
- package/web-app/dist/assets/{code-xml-Bbom7YWV.js → code-xml-dY70LCHD.js} +1 -1
- package/web-app/dist/assets/{database-CLcofiVp.js → database-DObn5fmn.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-C5mkjXIM.js → dollar-sign-DSu6n3K3.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-CVrkaOQP.js → file-code-corner-DpCnS73z.js} +1 -1
- package/web-app/dist/assets/{file-plus-BV9240gX.js → file-plus-D3IPKCf1.js} +1 -1
- package/web-app/dist/assets/{globe-aZ0Zso4I.js → globe-C9QXkgH_.js} +1 -1
- package/web-app/dist/assets/{hammer-EgOQNukG.js → hammer-D_FtWe1y.js} +1 -1
- package/web-app/dist/assets/index-CI54rara.css +1 -0
- package/web-app/dist/assets/{index-DvZ0Kk2x.js → index-Cz7q1bvC.js} +2 -2
- package/web-app/dist/assets/{layers-BOVKmepE.js → layers-DsW7jnJX.js} +1 -1
- package/web-app/dist/assets/{loader-circle-0K8yLgHl.js → loader-circle-B3HspjKR.js} +1 -1
- package/web-app/dist/assets/{lock-XzlDMy8M.js → lock-DSI29pct.js} +1 -1
- package/web-app/dist/assets/{package-BtXTKusI.js → package-CLNxtA2x.js} +1 -1
- package/web-app/dist/assets/{plus-PxeiD8ju.js → plus-DyI4WC4_.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-CjqzYczt.js → refresh-cw-D2dv0xA0.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CY0os4DH.js → rotate-ccw-CBvihA-a.js} +1 -1
- package/web-app/dist/assets/scroll-text-DvoOvAl_.js +6 -0
- package/web-app/dist/assets/{server-pIIs9vNp.js → server-DenWvSUt.js} +1 -1
- package/web-app/dist/assets/{shield-alert-CsDqKXwA.js → shield-alert-40cX_FvG.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-CAh3HVzH.js → thumbs-up-BrjZ6QuV.js} +12 -7
- package/web-app/dist/assets/trash-2-C6evwkEH.js +16 -0
- package/web-app/dist/assets/{trending-down-DrtBd_C9.js → trending-down-CfbZis3O.js} +1 -1
- package/web-app/dist/assets/{trending-up-D-DbCHXm.js → trending-up-DWuEK3WC.js} +1 -1
- package/web-app/dist/assets/{upload-D4l6DCOt.js → upload-B9DFBcPG.js} +1 -1
- package/web-app/dist/assets/{usePolling-D0aoTX80.js → usePolling-BryVC-T1.js} +1 -1
- package/web-app/dist/assets/{user-BQf_XJO_.js → user-DMRXwZiw.js} +1 -1
- package/web-app/dist/index.html +2 -2
- package/web-app/server.py +122 -47
- package/web-app/dist/assets/AdminPage-y6_b2Xo4.js +0 -32
- package/web-app/dist/assets/Avatar-BFPkXhIJ.js +0 -6
- package/web-app/dist/assets/CockpitPage-5TKICCoA.js +0 -47
- package/web-app/dist/assets/ComparePage-BSuNsUHy.js +0 -1
- package/web-app/dist/assets/EvidenceReceiptPanel-CcaBwEI-.js +0 -1
- package/web-app/dist/assets/GitHubIssuesPanel-DJeYgzAa.js +0 -12
- package/web-app/dist/assets/HomePage-Bs451LAy.js +0 -28
- package/web-app/dist/assets/MetricsPage-DvY1csgz.js +0 -6
- package/web-app/dist/assets/ProjectPage-CqHkEZkl.js +0 -257
- package/web-app/dist/assets/ProjectsPage-CLvCK1Y8.js +0 -6
- package/web-app/dist/assets/SettingsPage-DP7OmdwP.js +0 -10
- package/web-app/dist/assets/SystemSettingsPage-P_rlLh0x.js +0 -1
- package/web-app/dist/assets/TeamsPage-DxtBDcBC.js +0 -11
- package/web-app/dist/assets/TemplatesPage-5e-faH3W.js +0 -29
- package/web-app/dist/assets/folder-open-D_6yV78G.js +0 -11
- package/web-app/dist/assets/index-BSgmPwC0.css +0 -1
- package/web-app/dist/assets/lightbulb-DdASgqOG.js +0 -6
- package/web-app/dist/assets/mail-COu670_W.js +0 -6
- package/web-app/dist/assets/minus-bQpUt9ff.js +0 -6
- package/web-app/dist/assets/save-v8qep5cY.js +0 -6
- package/web-app/dist/assets/trash-2-Dt6urTyY.js +0 -6
package/autonomy/run.sh
CHANGED
|
@@ -8765,10 +8765,20 @@ start_resource_monitor() {
|
|
|
8765
8765
|
# Initial check
|
|
8766
8766
|
check_system_resources
|
|
8767
8767
|
|
|
8768
|
-
# Background monitoring loop
|
|
8768
|
+
# Background monitoring loop. BACKLOG 22: a bare `sleep N` inside this
|
|
8769
|
+
# subshell survives a TERM to the subshell -- caught traps do not carry
|
|
8770
|
+
# into a foreground child, so the sleep reparents to init and outlives the
|
|
8771
|
+
# run for up to RESOURCE_CHECK_INTERVAL seconds. Backgrounding the sleep
|
|
8772
|
+
# and recording its PID lets a TERM trap kill it directly; `wait` returns
|
|
8773
|
+
# immediately once the trap fires, so shutdown is not delayed either.
|
|
8769
8774
|
(
|
|
8775
|
+
_loki_rm_sleep_pid=""
|
|
8776
|
+
trap '[ -n "$_loki_rm_sleep_pid" ] && kill "$_loki_rm_sleep_pid" 2>/dev/null; exit 0' TERM
|
|
8770
8777
|
while true; do
|
|
8771
|
-
sleep "$RESOURCE_CHECK_INTERVAL"
|
|
8778
|
+
sleep "$RESOURCE_CHECK_INTERVAL" &
|
|
8779
|
+
_loki_rm_sleep_pid=$!
|
|
8780
|
+
wait "$_loki_rm_sleep_pid"
|
|
8781
|
+
_loki_rm_sleep_pid=""
|
|
8772
8782
|
check_system_resources
|
|
8773
8783
|
done
|
|
8774
8784
|
) &
|
|
@@ -9091,8 +9101,10 @@ setup_agent_branch() {
|
|
|
9091
9101
|
[ -f .loki/.gitignore ] || printf '*\n' > .loki/.gitignore 2>/dev/null || true
|
|
9092
9102
|
|
|
9093
9103
|
# Capture the ref Loki was run from. Detached HEAD yields the literal "HEAD".
|
|
9104
|
+
# symbolic-ref also names an unborn branch (fresh `git init`), where
|
|
9105
|
+
# rev-parse printed "HEAD" and failed, recording "HEAD" twice as the base.
|
|
9094
9106
|
local cur=""
|
|
9095
|
-
cur="$(git
|
|
9107
|
+
cur="$(git symbolic-ref --short -q HEAD 2>/dev/null || echo HEAD)"
|
|
9096
9108
|
|
|
9097
9109
|
# Detached HEAD: do NOT branch, do NOT fabricate a base (LOCK A2/A6).
|
|
9098
9110
|
if [ "$cur" = "HEAD" ]; then
|
|
@@ -9135,15 +9147,13 @@ setup_agent_branch() {
|
|
|
9135
9147
|
fi
|
|
9136
9148
|
fi
|
|
9137
9149
|
|
|
9138
|
-
# Fresh run:
|
|
9139
|
-
#
|
|
9150
|
+
# Fresh run: mint and check out the feature branch, then persist its base
|
|
9151
|
+
# (fresh-run-only, LOCK A2).
|
|
9140
9152
|
local timestamp
|
|
9141
9153
|
timestamp=$(date +%s)
|
|
9142
9154
|
local branch_name="loki/session-${timestamp}-$$"
|
|
9143
9155
|
|
|
9144
9156
|
mkdir -p .loki/state 2>/dev/null || true
|
|
9145
|
-
# Persist the base only once per run tree; never overwrite an existing base.
|
|
9146
|
-
[ ! -s .loki/state/base-branch.txt ] && printf '%s\n' "$cur" > .loki/state/base-branch.txt 2>/dev/null
|
|
9147
9157
|
|
|
9148
9158
|
log_info "Branch protection enabled - creating agent branch: $branch_name (base: $cur)"
|
|
9149
9159
|
|
|
@@ -9170,8 +9180,23 @@ setup_agent_branch() {
|
|
|
9170
9180
|
log_warn "Left uncommitted from an earlier unfinished session, now treated as your files: $leftover"
|
|
9171
9181
|
fi
|
|
9172
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
|
|
9173
9189
|
if [ "$snap_ok" = 1 ]; then _LOKI_SNAPSHOT_THIS_RUN=1; fi
|
|
9174
9190
|
|
|
9191
|
+
# Every mint (a refused resume included) records its own base and the
|
|
9192
|
+
# commit it starts at (the empty tree when unborn); the resume paths above
|
|
9193
|
+
# keep both. A base left by an earlier session can name a branch deleted
|
|
9194
|
+
# since, and _loki_untrack_agent_committed_user_files then could not find
|
|
9195
|
+
# the user files the agent commits.
|
|
9196
|
+
printf '%s\n' "$cur" > .loki/state/base-branch.txt 2>/dev/null
|
|
9197
|
+
{ git rev-parse --verify -q HEAD || git hash-object -t tree /dev/null; } \
|
|
9198
|
+
> .loki/state/session-start-sha 2>/dev/null
|
|
9199
|
+
|
|
9175
9200
|
# Store the branch name for later use (PR creation, cleanup)
|
|
9176
9201
|
printf '%s\n' "$branch_name" > .loki/state/agent-branch.txt 2>/dev/null
|
|
9177
9202
|
|
|
@@ -9194,6 +9219,352 @@ _loki_snapshot_or_fail_closed() {
|
|
|
9194
9219
|
return 1
|
|
9195
9220
|
}
|
|
9196
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
|
+
|
|
9197
9568
|
# _loki_snapshot_preexisting [union]
|
|
9198
9569
|
# Write .loki/state/preexisting-untracked.z: every path git does not track,
|
|
9199
9570
|
# untracked and gitignored, repo-wide, NUL-delimited, relative to the repo top.
|
|
@@ -9202,33 +9573,90 @@ _loki_snapshot_or_fail_closed() {
|
|
|
9202
9573
|
# a directory only when an ignore pattern matches it, so node_modules/ is one
|
|
9203
9574
|
# "dir/" entry covering its subtree, while logs/ holding only *.log files is
|
|
9204
9575
|
# listed file by file (a new logs/app.json is still the agent's). "union" keeps
|
|
9205
|
-
# the paths already recorded
|
|
9206
|
-
#
|
|
9207
|
-
#
|
|
9208
|
-
#
|
|
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.
|
|
9209
9602
|
_loki_snapshot_preexisting() {
|
|
9210
|
-
local snap=".loki/state/preexisting-untracked.z" top="" base="" exclude=""
|
|
9603
|
+
local snap=".loki/state/preexisting-untracked.z" top="" base="" exclude="" tracked="" gittool="" pytool=""
|
|
9211
9604
|
rm -f "${snap%.z}.sha.z" 2>/dev/null
|
|
9212
|
-
|
|
9605
|
+
gittool="$(_loki_snapshot_git_tool)" || return 1
|
|
9606
|
+
top="$("$gittool" rev-parse --show-toplevel 2>/dev/null)" || return 1
|
|
9213
9607
|
mkdir -p .loki/state 2>/dev/null || return 1
|
|
9214
9608
|
# "union" never adds a path listed in session-created.z: those files were
|
|
9215
9609
|
# made by a session that did not finish (interrupt, pod loss), so the
|
|
9216
9610
|
# session that finishes the work commits them. If the user edits one of
|
|
9217
9611
|
# them between sessions it is still the session's file and is committed
|
|
9218
|
-
# with the edit. Recorded entries only block additions;
|
|
9219
|
-
#
|
|
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).
|
|
9220
9615
|
if [ "${1:-}" = union ]; then
|
|
9221
9616
|
base="$snap"
|
|
9222
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
|
|
9223
9623
|
fi
|
|
9224
9624
|
if ! _loki_untracked_status "$snap.status" \
|
|
9225
|
-
|| ! _loki_untracked_merge "$snap.status" "$base" "$exclude" exact "$snap"; then
|
|
9226
|
-
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"
|
|
9227
9627
|
return 1
|
|
9228
9628
|
fi
|
|
9229
|
-
rm -f "$snap.status"
|
|
9230
|
-
|
|
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 \
|
|
9231
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
|
|
9232
9660
|
return 0
|
|
9233
9661
|
}
|
|
9234
9662
|
|
|
@@ -9236,32 +9664,57 @@ _loki_snapshot_preexisting() {
|
|
|
9236
9664
|
# and the session-created record, so the two always agree: raw
|
|
9237
9665
|
# `git status --porcelain -z` records for the whole repo, .loki excluded.
|
|
9238
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.
|
|
9239
9673
|
_loki_untracked_status() {
|
|
9240
|
-
local top="" prefix=""
|
|
9241
|
-
|
|
9242
|
-
|
|
9243
|
-
|
|
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 \
|
|
9244
9679
|
--ignored=matching --ignore-submodules=all -- ":(exclude,literal)${prefix}.loki" \
|
|
9245
9680
|
> "$1" 2>/dev/null && return 0
|
|
9246
9681
|
rm -f "$1"
|
|
9247
9682
|
return 1
|
|
9248
9683
|
}
|
|
9249
9684
|
|
|
9250
|
-
# _loki_untracked_merge <status> <base> <exclude> exact|cover <out>
|
|
9251
|
-
# Atomically write <out>: the NUL-delimited paths of <base
|
|
9252
|
-
#
|
|
9253
|
-
#
|
|
9254
|
-
#
|
|
9255
|
-
#
|
|
9256
|
-
#
|
|
9257
|
-
#
|
|
9258
|
-
#
|
|
9259
|
-
#
|
|
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`.
|
|
9260
9710
|
_loki_untracked_merge() {
|
|
9261
|
-
|
|
9711
|
+
local pytool=""
|
|
9712
|
+
pytool="$(_loki_snapshot_py_tool)" || return 1
|
|
9713
|
+
"$pytool" -I -S -c 'import sys
|
|
9262
9714
|
sys.path[:] = [p for p in sys.path if p not in ("", ".")]
|
|
9263
9715
|
import os
|
|
9264
9716
|
status, base, exclude, mode, out = sys.argv[1:6]
|
|
9717
|
+
tracked = sys.argv[6] if len(sys.argv) > 6 else ""
|
|
9265
9718
|
def entries(path, missing_ok=True):
|
|
9266
9719
|
try:
|
|
9267
9720
|
with open(path, "rb") as fh:
|
|
@@ -9281,6 +9734,11 @@ def covered(path):
|
|
|
9281
9734
|
cut = path.find(b"/", cut + 1)
|
|
9282
9735
|
return False
|
|
9283
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))
|
|
9284
9742
|
for rec in entries(status, missing_ok=False):
|
|
9285
9743
|
# The session-created record ("cover" mode) never holds a directory entry:
|
|
9286
9744
|
# a whole-directory entry (logs/, out/) would stop the resume union from
|
|
@@ -9296,6 +9754,80 @@ with open(out + ".tmp", "wb") as fh:
|
|
|
9296
9754
|
os.replace(out + ".tmp", out)' "$@" 2>/dev/null
|
|
9297
9755
|
}
|
|
9298
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
|
+
|
|
9299
9831
|
# _loki_record_session_created
|
|
9300
9832
|
# Union into .loki/state/session-created.z (NUL-delimited, repo-top-relative,
|
|
9301
9833
|
# atomic) the paths git does not track now that the pre-existing snapshot does
|
|
@@ -9313,6 +9845,15 @@ os.replace(out + ".tmp", out)' "$@" 2>/dev/null
|
|
|
9313
9845
|
_loki_record_session_created() {
|
|
9314
9846
|
local snap=".loki/state/preexisting-untracked.z" out=".loki/state/session-created.z"
|
|
9315
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
|
|
9316
9857
|
if [ -f "$snap" ] && _loki_untracked_status "$out.status" \
|
|
9317
9858
|
&& _loki_untracked_merge "$out.status" "$out" "$snap" cover "$out"; then
|
|
9318
9859
|
rm -f "$out.status"
|
|
@@ -9367,6 +9908,119 @@ fi
|
|
|
9367
9908
|
if ! type _commit_scan_secret_file >/dev/null 2>&1; then _commit_scan_secret_file() { return 1; }; fi
|
|
9368
9909
|
if ! type _commit_path_looks_secret >/dev/null 2>&1; then _commit_path_looks_secret() { return 1; }; fi
|
|
9369
9910
|
|
|
9911
|
+
# _loki_covered_paths <paths> <snapshot> <top> <out>
|
|
9912
|
+
# Atomically write <out>: the NUL-delimited entries of <paths> that <snapshot>
|
|
9913
|
+
# covers (the entry itself or a parent "dir/" entry, as workspace_diff._covered)
|
|
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.
|
|
9925
|
+
_loki_covered_paths() {
|
|
9926
|
+
local pytool=""
|
|
9927
|
+
pytool="$(_loki_snapshot_py_tool)" || return 1
|
|
9928
|
+
"$pytool" -I -S -c 'import sys
|
|
9929
|
+
sys.path[:] = [p for p in sys.path if p not in ("", ".")]
|
|
9930
|
+
import os
|
|
9931
|
+
paths, snap, top, out = sys.argv[1:5]
|
|
9932
|
+
def entries(path):
|
|
9933
|
+
with open(path, "rb") as fh:
|
|
9934
|
+
return [p for p in fh.read().split(b"\0") if p]
|
|
9935
|
+
held = set(entries(snap))
|
|
9936
|
+
def covered(path):
|
|
9937
|
+
if path in held:
|
|
9938
|
+
return True
|
|
9939
|
+
cut = path.find(b"/")
|
|
9940
|
+
while cut != -1:
|
|
9941
|
+
if path[:cut + 1] in held:
|
|
9942
|
+
return True
|
|
9943
|
+
cut = path.find(b"/", cut + 1)
|
|
9944
|
+
return False
|
|
9945
|
+
# A file gone from disk stays tracked: untracking it would leave its bytes
|
|
9946
|
+
# only in history.
|
|
9947
|
+
top = os.fsencode(top)
|
|
9948
|
+
hits = [p for p in entries(paths) if covered(p) and os.path.lexists(os.path.join(top, p))]
|
|
9949
|
+
with open(out + ".tmp", "wb") as fh:
|
|
9950
|
+
fh.write(b"".join(p + b"\0" for p in hits))
|
|
9951
|
+
os.replace(out + ".tmp", out)' "$@" 2>/dev/null
|
|
9952
|
+
}
|
|
9953
|
+
|
|
9954
|
+
# _loki_untrack_agent_committed_user_files <branch> (BACKLOG 74)
|
|
9955
|
+
# An agent that rewrites .gitignore and runs `git add -A && git commit` itself
|
|
9956
|
+
# puts the user's untracked or ignored files on the session branch, and a
|
|
9957
|
+
# checkout of the base then deletes them from disk. Files ADDED on the branch
|
|
9958
|
+
# since it forked from the recorded base that the pre-existing snapshot covers
|
|
9959
|
+
# are removed from the index (they stay on disk) and that removal is committed
|
|
9960
|
+
# on its own, before the session commit: it adds no content, and no later exit
|
|
9961
|
+
# (a secret abort, a failed unstage) can put the files back on the branch tip.
|
|
9962
|
+
# Records this session's removals in .loki/state/agent-committed-user-files.z
|
|
9963
|
+
# and warns that the branch history still holds them. The fork is the merge
|
|
9964
|
+
# base with the recorded base branch, else the session's recorded start commit
|
|
9965
|
+
# (.loki/state/session-start-sha). Non-zero when the removal could not be
|
|
9966
|
+
# committed, or when neither fork resolves or the check fails (the tip may hold
|
|
9967
|
+
# user files): the caller then makes no session commit, whose `git add -A`
|
|
9968
|
+
# would stage the files again.
|
|
9969
|
+
_loki_untrack_agent_committed_user_files() {
|
|
9970
|
+
local snap=".loki/state/preexisting-untracked.z" rec=".loki/state/agent-committed-user-files.z"
|
|
9971
|
+
local base="" fork="" start="" top="" names=""
|
|
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
|
|
9980
|
+
[ -s "$snap" ] || return 0
|
|
9981
|
+
# No commit on the branch yet: the agent committed nothing.
|
|
9982
|
+
git rev-parse --verify -q HEAD >/dev/null 2>&1 || return 0
|
|
9983
|
+
[ -s .loki/state/base-branch.txt ] && base="$(cat .loki/state/base-branch.txt 2>/dev/null)"
|
|
9984
|
+
if [ -n "$base" ]; then fork="$(git merge-base HEAD "$base" 2>/dev/null)" || fork=""; fi
|
|
9985
|
+
if [ -z "$fork" ] && [ -s .loki/state/session-start-sha ]; then
|
|
9986
|
+
start="$(cat .loki/state/session-start-sha 2>/dev/null)"
|
|
9987
|
+
# A commit on this branch, or the empty tree of an unborn mint.
|
|
9988
|
+
if git merge-base --is-ancestor "$start" HEAD 2>/dev/null \
|
|
9989
|
+
|| [ "$(git cat-file -t "$start" 2>/dev/null)" = tree ]; then
|
|
9990
|
+
fork="$start"
|
|
9991
|
+
fi
|
|
9992
|
+
fi
|
|
9993
|
+
if [ -z "$fork" ]; then
|
|
9994
|
+
log_warn "Could not check whether the agent committed your pre-existing untracked files on $1: base branch '${base:-unknown}' not found and the recorded session start commit is missing or not on this branch. ${unchecked}"
|
|
9995
|
+
return 1
|
|
9996
|
+
fi
|
|
9997
|
+
# --no-renames: a user file swept in beside a deleted tracked file must
|
|
9998
|
+
# read as added, not as a rename of that file.
|
|
9999
|
+
if ! top="$(git rev-parse --show-toplevel 2>/dev/null)" \
|
|
10000
|
+
|| ! git -C "$top" diff --name-only -z --no-renames --diff-filter=A "$fork" HEAD > "$rec.added" 2>/dev/null \
|
|
10001
|
+
|| ! _loki_covered_paths "$rec.added" "$snap" "$top" "$rec.new"; then
|
|
10002
|
+
rm -f "$rec.added" "$rec.new" "$rec.new.tmp"
|
|
10003
|
+
log_warn "Could not check whether the agent committed your pre-existing untracked files on $1. ${unchecked}"
|
|
10004
|
+
return 1
|
|
10005
|
+
fi
|
|
10006
|
+
rm -f "$rec.added"
|
|
10007
|
+
if [ ! -s "$rec.new" ]; then
|
|
10008
|
+
rm -f "$rec.new"
|
|
10009
|
+
return 0
|
|
10010
|
+
fi
|
|
10011
|
+
mv -f "$rec.new" "$rec"
|
|
10012
|
+
names="$(_loki_nul_names "$rec")"
|
|
10013
|
+
git reset -q >/dev/null 2>&1 || true
|
|
10014
|
+
if ! git -C "$top" update-index -z --force-remove --stdin < "$rec" >/dev/null 2>&1 \
|
|
10015
|
+
|| ! git commit -q -m "Loki Mode: untrack pre-existing user files the agent committed" >/dev/null 2>&1; then
|
|
10016
|
+
log_warn "The agent committed your pre-existing files on $1 and Loki could not remove them from the branch: ${names}. They are on disk. Before switching branches, run git rm --cached on each and commit, or a checkout of the base deletes them. Left uncommitted: no session commit."
|
|
10017
|
+
return 1
|
|
10018
|
+
fi
|
|
10019
|
+
log_warn "The agent committed your pre-existing untracked or ignored files on $1: ${names}. Loki removed them from the branch tip; they stay on disk, untracked. The branch history still holds them: do not push $1 as-is. To drop them from its history: git reset --soft ${fork} && git commit"
|
|
10020
|
+
audit_agent_action "git_untrack_user_files" "Removed pre-existing user files the agent committed" "files=${names}" || true
|
|
10021
|
+
return 0
|
|
10022
|
+
}
|
|
10023
|
+
|
|
9370
10024
|
commit_session_changes() {
|
|
9371
10025
|
# Squash the session's work into one honest session-end commit on the agent
|
|
9372
10026
|
# branch (LOCK A3/A4/A8). Commit-always (incl. failed runs) so the user is
|
|
@@ -9401,6 +10055,43 @@ commit_session_changes() {
|
|
|
9401
10055
|
;;
|
|
9402
10056
|
esac
|
|
9403
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
|
+
|
|
10067
|
+
# Commit nothing unless THIS run took a current snapshot (BACKLOG 90). The
|
|
10068
|
+
# LOCK A1 opt-out takes none, so it never commits, even on a leftover
|
|
10069
|
+
# loki/session-* branch whose agent-branch.txt and snapshot belong to an
|
|
10070
|
+
# earlier session: that stale snapshot would sweep in a file the user made
|
|
10071
|
+
# since, and a checkout of the base would delete it. Checked before any
|
|
10072
|
+
# staging, so the opt-out leaves the user's index alone.
|
|
10073
|
+
if [ "${_LOKI_SNAPSHOT_THIS_RUN:-0}" != 1 ]; then
|
|
10074
|
+
if [ "${LOKI_BRANCH_PROTECTION:-true}" != "true" ]; then
|
|
10075
|
+
log_info "Branch protection off (LOKI_BRANCH_PROTECTION=${LOKI_BRANCH_PROTECTION}): no session commit; the work stays uncommitted on ${cur}"
|
|
10076
|
+
elif [ -f "$PWD/.loki/state/preexisting-untracked.failed" ]; then
|
|
10077
|
+
log_warn "Left uncommitted: could not record your pre-existing untracked files at session start (the snapshot needs git 2.18+, excluding them needs 2.25+). Review and commit manually."
|
|
10078
|
+
else
|
|
10079
|
+
log_warn "Left uncommitted: this run recorded no snapshot of your pre-existing untracked files, so it makes no session commit. Review and commit manually."
|
|
10080
|
+
fi
|
|
10081
|
+
return 0
|
|
10082
|
+
fi
|
|
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
|
+
|
|
10093
|
+
_loki_untrack_agent_committed_user_files "$cur" || return 0
|
|
10094
|
+
|
|
9404
10095
|
# Stage everything except .loki/ runtime state and a secret-path denylist.
|
|
9405
10096
|
# These excludes are defense-in-depth ONLY for the top-level cases git
|
|
9406
10097
|
# pathspec handles cleanly. We deliberately do NOT add nested globs like
|
|
@@ -9426,12 +10117,8 @@ commit_session_changes() {
|
|
|
9426
10117
|
# Agent-created files stay staged. Skip an empty snapshot: an
|
|
9427
10118
|
# empty --pathspec-from-file resets the WHOLE index. No snapshot (older
|
|
9428
10119
|
# session): behave as before. If the unstage fails (git < 2.25), commit
|
|
9429
|
-
# nothing rather than sweep the user's files in.
|
|
9430
|
-
|
|
9431
|
-
git reset -q >/dev/null 2>&1 || true
|
|
9432
|
-
log_warn "Left uncommitted: could not record your pre-existing untracked files at session start (the snapshot needs git 2.18+, excluding them needs 2.25+). Review and commit manually."
|
|
9433
|
-
return 0
|
|
9434
|
-
fi
|
|
10120
|
+
# nothing rather than sweep the user's files in. A failed snapshot never
|
|
10121
|
+
# gets here (the snapshot guard above returns first).
|
|
9435
10122
|
local preexisting="$PWD/.loki/state/preexisting-untracked.z" top=""
|
|
9436
10123
|
if [ -s "$preexisting" ]; then
|
|
9437
10124
|
if ! { top="$(git rev-parse --show-toplevel 2>/dev/null)" \
|
|
@@ -12089,6 +12776,12 @@ ZT_MATCHES_EOF
|
|
|
12089
12776
|
}
|
|
12090
12777
|
|
|
12091
12778
|
enforce_test_coverage() {
|
|
12779
|
+
# Every suite this gate launches (pytest, unittest, the coverage pass, a
|
|
12780
|
+
# package script that runs Python) runs inside the user's repo: no
|
|
12781
|
+
# __pycache__/*.pyc there, so none can land in the session commit when the
|
|
12782
|
+
# repo does not ignore them (BACKLOG 85). Bytecode caching only; the same
|
|
12783
|
+
# tests run. Scoped to this call.
|
|
12784
|
+
local -x PYTHONDONTWRITEBYTECODE=1
|
|
12092
12785
|
local loki_dir="${TARGET_DIR:-.}/.loki"
|
|
12093
12786
|
local quality_dir="$loki_dir/quality"
|
|
12094
12787
|
mkdir -p "$quality_dir" "$loki_dir/signals"
|
|
@@ -12118,7 +12811,9 @@ enforce_test_coverage() {
|
|
|
12118
12811
|
# that is the legitimate case they were written for (a package that ships
|
|
12119
12812
|
# a runner but no npm script).
|
|
12120
12813
|
local _declared_test_script=""
|
|
12121
|
-
|
|
12814
|
+
# BACKLOG 81 (D7): -E plus the sys.path filter, so the agent's repo (the
|
|
12815
|
+
# cwd) cannot supply json or sitecustomize to this reader.
|
|
12816
|
+
_declared_test_script=$(_LOKI_PKG="${TARGET_DIR:-.}/package.json" python3 -E -c "import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]
|
|
12122
12817
|
import json, os, sys
|
|
12123
12818
|
try:
|
|
12124
12819
|
with open(os.environ['_LOKI_PKG']) as f:
|
|
@@ -12238,7 +12933,8 @@ sys.stdout.write(t.strip())
|
|
|
12238
12933
|
# npm test), so the grep only ever picked the LABEL, never
|
|
12239
12934
|
# what ran. This corrects the evidence, not the execution.
|
|
12240
12935
|
local _ws_script
|
|
12241
|
-
|
|
12936
|
+
# BACKLOG 81 (D7): same guard as the root reader above.
|
|
12937
|
+
_ws_script=$(_LOKI_PKG="$pkg_json" python3 -E -c "import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]
|
|
12242
12938
|
import json, os, sys
|
|
12243
12939
|
try:
|
|
12244
12940
|
with open(os.environ['_LOKI_PKG']) as f:
|
|
@@ -12558,6 +13254,9 @@ TREOF
|
|
|
12558
13254
|
# run instead of re-running the suite. Single source of truth for "tests
|
|
12559
13255
|
# ran this iteration", set on every return path that writes results.
|
|
12560
13256
|
printf '%s\n' "${ITERATION_COUNT:-0}" > "$quality_dir/.test-results.iter" 2>/dev/null || true
|
|
13257
|
+
# BACKLOG 91: non-blocking, but nothing was measured: the loop's
|
|
13258
|
+
# stage_complete event reads this and says not_run, not pass.
|
|
13259
|
+
_LOKI_TEST_SUITE_STATUS=not_run
|
|
12561
13260
|
return 0
|
|
12562
13261
|
fi
|
|
12563
13262
|
|
|
@@ -12569,6 +13268,49 @@ TREOF
|
|
|
12569
13268
|
# first, then delete every remaining control char (\000-\037, incl. ESC).
|
|
12570
13269
|
details=$(echo "$details" | tr '"' "'" | tr '\n' ' ' | tr -d '\000-\037' | head -c 500)
|
|
12571
13270
|
|
|
13271
|
+
# BACKLOG 73: a runner that exits 0 while its own summary reports failures
|
|
13272
|
+
# (`jest || true`, a script that swallows the status) failed. The failure
|
|
13273
|
+
# count comes from the runner's summary lines in the FULL output (the details
|
|
13274
|
+
# tail can drop them: the jest branch keeps 3 lines). The highest count
|
|
13275
|
+
# across summaries wins, so output that concatenates several runs (npm test
|
|
13276
|
+
# --workspaces, `jest; vitest`) cannot hide a failed run behind a later
|
|
13277
|
+
# green one. Shapes: jest "Tests: 1 failed, ...", vitest "Tests 1 failed |
|
|
13278
|
+
# ...", pytest "=== 1 failed, 2 passed in 0.1s ===" (or its -q line), node
|
|
13279
|
+
# TAP "# fail 1" and the spec reporter's "<info sign> fail 1", counted only
|
|
13280
|
+
# right after the matching pass line (node TAP echoes a test's own stdout
|
|
13281
|
+
# as "# ..." lines, which must not count), mocha "1 failing", go "--- FAIL:"
|
|
13282
|
+
# lines. A read summary with no failures is a measured 0. With no recognised
|
|
13283
|
+
# summary the old best-effort parse of the tail still applies (null when it
|
|
13284
|
+
# finds nothing). Either way a recorded count above zero is a failure: the
|
|
13285
|
+
# council evidence gate and the Bun gate both read failed_count > 0 as one.
|
|
13286
|
+
# LC_ALL=C: bytes, so any runner output parses; [ \t] not [[:space:]] for
|
|
13287
|
+
# old mawk.
|
|
13288
|
+
local _tr_passed_n _tr_failed_n _tr_summary_red=false
|
|
13289
|
+
_tr_failed_n=$(printf '%s\n' "${output:-}" | LC_ALL=C sed "s/$(printf '\033')\[[0-9;]*[A-Za-z]//g" | LC_ALL=C awk '
|
|
13290
|
+
function upd(n) { if (n > f) f = n; seen = 1 }
|
|
13291
|
+
/^Tests:[ \t]/ || /^[ \t]*Tests[ \t]+[0-9]/ ||
|
|
13292
|
+
/^=+ .*[0-9]+ (passed|failed)/ || /^[0-9]+ (passed|failed).* in [0-9.]+s/ {
|
|
13293
|
+
n = 0; if (match($0, /[0-9]+ failed/)) n = substr($0, RSTART, RLENGTH) + 0; upd(n) }
|
|
13294
|
+
/^[^ A-Za-z0-9]+ fail [0-9]+[ \t]*$/ { if (prev ~ /^[^ A-Za-z0-9]+ pass [0-9]+[ \t]*$/) upd($3 + 0) }
|
|
13295
|
+
/^[ \t]*[0-9]+ passing \(/ { upd(0) }
|
|
13296
|
+
/^[ \t]*[0-9]+ failing[ \t]*$/ { upd($1 + 0) }
|
|
13297
|
+
/^--- FAIL: / { go++ }
|
|
13298
|
+
{ prev = $0 }
|
|
13299
|
+
END { if (go > f) { f = go; seen = 1 } if (seen) print f + 0 }')
|
|
13300
|
+
_tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
|
|
13301
|
+
[ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+' | head -1)
|
|
13302
|
+
# node --test emits TAP-ish "# pass N" / "# fail N" summary lines, which the
|
|
13303
|
+
# "N passed"/"N failed" pattern above does not match. Fall back to those.
|
|
13304
|
+
[ -n "$_tr_passed_n" ] || _tr_passed_n=$(printf '%s' "$details" | grep -oE '# pass [0-9]+' | grep -oE '[0-9]+' | head -1)
|
|
13305
|
+
[ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '# fail [0-9]+' | grep -oE '[0-9]+' | head -1)
|
|
13306
|
+
[ -n "$_tr_passed_n" ] || _tr_passed_n=null
|
|
13307
|
+
[ -n "$_tr_failed_n" ] || _tr_failed_n=null
|
|
13308
|
+
if [ "$test_passed" = "true" ] && [ "$_tr_failed_n" != "null" ] && [ "$_tr_failed_n" -gt 0 ] 2>/dev/null; then
|
|
13309
|
+
test_passed=false
|
|
13310
|
+
_tr_summary_red=true
|
|
13311
|
+
log_warn "Test suite gate: $test_runner exited 0 but its summary reports $_tr_failed_n failed -- recording a failure"
|
|
13312
|
+
fi
|
|
13313
|
+
|
|
12572
13314
|
# Evidence Receipt provenance (v7.85.0): record the deterministic FACTS a
|
|
12573
13315
|
# non-forgeable receipt needs -- the command that ran, its exit code, and a
|
|
12574
13316
|
# status enum -- alongside the legacy pass/runner/min_coverage keys the
|
|
@@ -12586,6 +13328,8 @@ TREOF
|
|
|
12586
13328
|
*) _tr_cmd="$test_runner" ;;
|
|
12587
13329
|
esac
|
|
12588
13330
|
if [ "$test_passed" = "true" ]; then _tr_exit=0; _tr_status="verified"; else _tr_exit=1; _tr_status="failed"; fi
|
|
13331
|
+
# BACKLOG 73: the runner itself exited 0; record that fact, not a made-up 1.
|
|
13332
|
+
if [ "$_tr_summary_red" = "true" ]; then _tr_exit=0; fi
|
|
12589
13333
|
|
|
12590
13334
|
# #82 (zero-test-file hardening): a runner that EXITED 0 but executed ZERO
|
|
12591
13335
|
# real tests is a mini fake-green -- it records "verified" while proving
|
|
@@ -12607,17 +13351,6 @@ TREOF
|
|
|
12607
13351
|
log_warn "Verification gap: $test_runner exited 0 but ran ZERO tests -- recording inconclusive (no_tests_run), not verified"
|
|
12608
13352
|
fi
|
|
12609
13353
|
|
|
12610
|
-
# Best-effort pass/fail counts from the summary text (null when not found).
|
|
12611
|
-
local _tr_passed_n _tr_failed_n
|
|
12612
|
-
_tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
|
|
12613
|
-
_tr_failed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+' | head -1)
|
|
12614
|
-
# node --test emits TAP-ish "# pass N" / "# fail N" summary lines, which the
|
|
12615
|
-
# "N passed"/"N failed" pattern above does not match. Fall back to those.
|
|
12616
|
-
[ -n "$_tr_passed_n" ] || _tr_passed_n=$(printf '%s' "$details" | grep -oE '# pass [0-9]+' | grep -oE '[0-9]+' | head -1)
|
|
12617
|
-
[ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '# fail [0-9]+' | grep -oE '[0-9]+' | head -1)
|
|
12618
|
-
[ -n "$_tr_passed_n" ] || _tr_passed_n=null
|
|
12619
|
-
[ -n "$_tr_failed_n" ] || _tr_failed_n=null
|
|
12620
|
-
|
|
12621
13354
|
# verification_gap is "none" whenever a real runner executed AND ran tests:
|
|
12622
13355
|
# the suite ran, so there is no docs-without-execution / source-without-tests
|
|
12623
13356
|
# gap. The #82 zero-test case is the exception -- a runner ran but executed
|
|
@@ -12787,9 +13520,11 @@ os.replace(tmp, out)
|
|
|
12787
13520
|
if [ "$_tr_zero_tests" = "true" ]; then
|
|
12788
13521
|
rm -f "$quality_dir/unit-tests.pass" 2>/dev/null || true
|
|
12789
13522
|
log_warn "Test suite gate: $test_runner ran zero tests -- inconclusive (not passed, not failed)"
|
|
13523
|
+
_LOKI_TEST_SUITE_STATUS=not_run # BACKLOG 91, see the no-runner return
|
|
12790
13524
|
else
|
|
12791
13525
|
touch "$quality_dir/unit-tests.pass"
|
|
12792
13526
|
log_info "Test suite gate: $test_runner passed"
|
|
13527
|
+
_LOKI_TEST_SUITE_STATUS=pass
|
|
12793
13528
|
fi
|
|
12794
13529
|
# Coverage block is distinct from tests-red: tests passed, but enforced
|
|
12795
13530
|
# coverage is below threshold. Return nonzero to gate WITHOUT writing the
|
|
@@ -19154,6 +19889,12 @@ PYEOF
|
|
|
19154
19889
|
_intelligent_usage_regen() {
|
|
19155
19890
|
local target_dir="${TARGET_DIR:-.}"
|
|
19156
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
|
|
19157
19898
|
# Find a working `claude` binary; if absent, bail silently.
|
|
19158
19899
|
if ! command -v claude >/dev/null 2>&1; then
|
|
19159
19900
|
return 0
|
|
@@ -23383,6 +24124,17 @@ except Exception as exc:
|
|
|
23383
24124
|
local exit_code=0
|
|
23384
24125
|
# v7.5.12: Mark provider pipeline as active so SIGINT trap can kill it.
|
|
23385
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
|
|
23386
24138
|
# v7.7.31: authorize autonomous operation at the system-prompt tier so
|
|
23387
24139
|
# the spawned agent does not read the user's global ~/.claude/CLAUDE.md,
|
|
23388
24140
|
# judge it to conflict with the loki_system prompt, call AskUserQuestion,
|
|
@@ -24587,8 +25339,12 @@ EOF
|
|
|
24587
25339
|
local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
|
|
24588
25340
|
# F49: isolate HOME so the project's suite cannot pollute the
|
|
24589
25341
|
# user's real home when it execs the generated app.
|
|
25342
|
+
_LOKI_TEST_SUITE_STATUS=""
|
|
24590
25343
|
if _loki_with_app_sandbox enforce_test_coverage; then
|
|
24591
25344
|
clear_gate_failure "test_coverage"
|
|
25345
|
+
# BACKLOG 91: a zero-test or no-runner run returns 0 without
|
|
25346
|
+
# measuring anything; its stage status is not_run, not pass.
|
|
25347
|
+
if [ "$_LOKI_TEST_SUITE_STATUS" = "not_run" ]; then _stg_ok=not_run; fi
|
|
24592
25348
|
else
|
|
24593
25349
|
_stg_ok=fail
|
|
24594
25350
|
local tc_count
|
|
@@ -25688,11 +26444,33 @@ kill_provider_child() {
|
|
|
25688
26444
|
fi
|
|
25689
26445
|
kill -TERM "$child_pid" 2>/dev/null && killed=1
|
|
25690
26446
|
done
|
|
25691
|
-
# Also kill provider leaf processes
|
|
25692
|
-
|
|
25693
|
-
|
|
25694
|
-
|
|
25695
|
-
|
|
26447
|
+
# Also kill provider leaf processes in case they were reparented (to init,
|
|
26448
|
+
# once their immediate parent -- a shell or the provider CLI itself --
|
|
26449
|
+
# exited). A reparented process changes PARENT, not PROCESS GROUP: unless
|
|
26450
|
+
# a process explicitly calls setpgid/setsid, it keeps the pgid it was
|
|
26451
|
+
# launched into, which for every invoke_* call above is this shell's own
|
|
26452
|
+
# pgid ($$). Scope the sweep to that pgid, matched by process name, rather
|
|
26453
|
+
# than a bare `pkill -f "^claude( |$)"` -- that matched the FULL COMMAND
|
|
26454
|
+
# LINE of every "claude", "codex", "aider" or "cline" process on the whole
|
|
26455
|
+
# machine, with no scoping at all, and killed unrelated Claude Code
|
|
26456
|
+
# sessions in other terminals the moment ANY loki-mode run finished,
|
|
26457
|
+
# double-Ctrl-C'd, or Ctrl-C'd in perpetual mode (build prompt D14 and
|
|
26458
|
+
# `feedback-pkill-f-substring-killed-the-session`). Reproduced: a decoy
|
|
26459
|
+
# `claude` process in a DIFFERENT process group survived; one launched
|
|
26460
|
+
# inside this shell's job (matching how a real reparented leaf would sit)
|
|
26461
|
+
# was still caught.
|
|
26462
|
+
local proc my_pgid
|
|
26463
|
+
my_pgid="$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')"
|
|
26464
|
+
if [ -n "$my_pgid" ]; then
|
|
26465
|
+
for proc in claude codex aider cline; do
|
|
26466
|
+
local leaf_pid leaf_pgid
|
|
26467
|
+
for leaf_pid in $(pgrep -f "^${proc}( |$)" 2>/dev/null); do
|
|
26468
|
+
leaf_pgid="$(ps -o pgid= -p "$leaf_pid" 2>/dev/null | tr -d ' ')"
|
|
26469
|
+
[ -n "$leaf_pgid" ] && [ "$leaf_pgid" = "$my_pgid" ] || continue
|
|
26470
|
+
kill -TERM "$leaf_pid" 2>/dev/null && killed=1
|
|
26471
|
+
done
|
|
26472
|
+
done
|
|
26473
|
+
fi
|
|
25696
26474
|
|
|
25697
26475
|
# Brief wait for graceful exit (max ~2s).
|
|
25698
26476
|
local i=0
|