Found by a release-gate security audit of v0.16.0..main while cutting v0.17.0. Pre-existing rather than introduced by #322/#325 — but it is the missing half of the guard that release exists to add, and the release note asserts the opposite of it.
Mechanism
case "$LAST_MOD" in ''|*[!0-9]*) LAST_MOD=0 ;; esac accepts any all-digit string. It rejects the syntax error; it does not bound the value.
A marker holding a future epoch — clock set back after an NTP correction, a VM snapshot restore, a container clock jump — or an overflowing integer produces a negative ELAPSED. [ "$ELAPSED" -lt "$SAVE_COOLDOWN" ] is then true, and the exit 0 fires.
The exit 0 sits above the line that rewrites the marker. So the self-heal that the whole fix rests on is unreachable on exactly the path that needs it:
$ /bin/bash -c 'for M in 99999999999999999999 9223372036854775807; do
LAST_MOD=$M; case "$LAST_MOD" in ""|*[!0-9]*) LAST_MOD=0;; esac
E=$(( $(date +%s) - 10#$LAST_MOD )); printf "marker=%-22s ELAPSED=%s " "$M" "$E"
if [ "$E" -lt 120 ]; then echo "-> COOLDOWN ENGAGED (exit 0, marker NOT rewritten)"; else echo "-> proceeds"; fi; done'
marker=99999999999999999999 ELAPSED=-7766279629666055955 -> COOLDOWN ENGAGED (exit 0, marker NOT rewritten)
marker=9223372036854775807 ELAPSED=-9223372035068589843 -> COOLDOWN ENGAGED (exit 0, marker NOT rewritten)
Every subsequent run skips, permanently, until the wall clock catches up or someone runs --force or deletes the file by hand. No stderr, nothing in hook-errors.log.
Four sites, and on each the rewrite is unreachable on the stuck path
| Site |
Stuck state |
Rewrite that never runs |
scripts/save-session.sh:169-177 |
no session is ever saved again |
:187 |
scripts/save-session.sh:600-604 |
NDC compression never runs again; now.md grows unbounded |
:622, inside if [ "$RUN_NDC" = true ] |
hooks.d/after_save/50-git-backup.sh:169-176 |
git backup permanently stopped — #258's original outage, reached through a value the guard accepts |
_gb_stamp_cooldown, :583/:586 |
scripts/post-tool-hook.sh:316-326 |
never forks a save again; compounds the first row |
this file never writes the marker |
Why this outranks the bug that was just fixed
The syntax-error case self-heals and leaves one line in the log. This one is permanent and mute — the class where a user discovers it by noticing that nothing has been saved for a week.
Class: fails-to-preserve. The data is still on the machine; what stops is the preservation.
The release note currently says the opposite
CHANGELOG.md and the code comment at scripts/save-session.sh:155-158 both state that both gates self-heal and that the cost is "one skipped cooldown per corruption event, not a throttle stuck off". True for the syntax-error case, false for this one — and the confident scoping is exactly what would make a maintainer deprioritise this report. Corrected in the v0.17.0 release commit.
Judgment call for whoever takes this
A range check is not simply "clamp negatives to 0". Decide what a future-dated marker means and say it out loud:
- Treat
ELAPSED < 0 as "the clock moved" and proceed (self-healing, matches the existing philosophy), or
- treat it as corruption, reset the marker to now, and emit a diagnostic.
The second is more honest but changes behaviour on a laptop that legitimately suspends across a DST or timezone shift. Whichever you pick, the marker rewrite has to become reachable on the stuck path — that is the structural half, and it is the same shape at all four sites.
Pin it with a test that asserts the behaviour (a second run does save), not the diagnostic — a fallback to 0 makes a corrupt marker read as "very old", so corrupt and clean-but-ancient reach the same decision and only a side effect separates broken from fixed.
Found by a release-gate security audit of
v0.16.0..mainwhile cutting v0.17.0. Pre-existing rather than introduced by #322/#325 — but it is the missing half of the guard that release exists to add, and the release note asserts the opposite of it.Mechanism
case "$LAST_MOD" in ''|*[!0-9]*) LAST_MOD=0 ;; esacaccepts any all-digit string. It rejects the syntax error; it does not bound the value.A marker holding a future epoch — clock set back after an NTP correction, a VM snapshot restore, a container clock jump — or an overflowing integer produces a negative
ELAPSED.[ "$ELAPSED" -lt "$SAVE_COOLDOWN" ]is then true, and theexit 0fires.The
exit 0sits above the line that rewrites the marker. So the self-heal that the whole fix rests on is unreachable on exactly the path that needs it:Every subsequent run skips, permanently, until the wall clock catches up or someone runs
--forceor deletes the file by hand. No stderr, nothing inhook-errors.log.Four sites, and on each the rewrite is unreachable on the stuck path
scripts/save-session.sh:169-177:187scripts/save-session.sh:600-604now.mdgrows unbounded:622, insideif [ "$RUN_NDC" = true ]hooks.d/after_save/50-git-backup.sh:169-176_gb_stamp_cooldown,:583/:586scripts/post-tool-hook.sh:316-326Why this outranks the bug that was just fixed
The syntax-error case self-heals and leaves one line in the log. This one is permanent and mute — the class where a user discovers it by noticing that nothing has been saved for a week.
Class:
fails-to-preserve. The data is still on the machine; what stops is the preservation.The release note currently says the opposite
CHANGELOG.mdand the code comment atscripts/save-session.sh:155-158both state that both gates self-heal and that the cost is "one skipped cooldown per corruption event, not a throttle stuck off". True for the syntax-error case, false for this one — and the confident scoping is exactly what would make a maintainer deprioritise this report. Corrected in the v0.17.0 release commit.Judgment call for whoever takes this
A range check is not simply "clamp negatives to 0". Decide what a future-dated marker means and say it out loud:
ELAPSED < 0as "the clock moved" and proceed (self-healing, matches the existing philosophy), orThe second is more honest but changes behaviour on a laptop that legitimately suspends across a DST or timezone shift. Whichever you pick, the marker rewrite has to become reachable on the stuck path — that is the structural half, and it is the same shape at all four sites.
Pin it with a test that asserts the behaviour (a second run does save), not the diagnostic — a fallback to
0makes a corrupt marker read as "very old", so corrupt and clean-but-ancient reach the same decision and only a side effect separates broken from fixed.