Skip to content

Feature: XACA-0341 — Session-state GC (lazy sweep + SessionEnd hook)#1

Merged
ehlersd merged 2 commits into
mainfrom
feature/xaca-0341-session-gc
May 6, 2026
Merged

Feature: XACA-0341 — Session-state GC (lazy sweep + SessionEnd hook)#1
ehlersd merged 2 commits into
mainfrom
feature/xaca-0341-session-gc

Conversation

@ehlersd
Copy link
Copy Markdown
Member

@ehlersd ehlersd commented May 6, 2026

Summary

Adds two-pronged garbage collection to claude-context-tick so per-session state files no longer accumulate forever in ~/.claude/state/time-inject/.

  • hooks/session-end.sh — new SessionEnd hook. Reads session_id from stdin, sanitizes via the same [A-Za-z0-9._-] whitelist already used by inject-time-context.sh, and removes that one file. Silent on failure. No kill-switch check.
  • Lazy sweep in inject-time-context.sh — backstop for sessions that ended dirty (hard kill, crash, OS reboot before SessionEnd fires). Rate-limited via the .gc-sweep marker file (one sweep per 24h). Prunes any session state file with mtime older than 7 days.
  • Liveness signal — the hook now touches the active state file on every prompt regardless of whether an injection fires, so a long-running quiet session is not mistakenly pruned.

Changes Made

File Change
hooks/inject-time-context.sh +46 lines — _gc_sweep_due() + _gc_run_sweep() + liveness touch
hooks/session-end.sh NEW (31 lines) — verbatim sanitization copy; one-file delete
scripts/install.sh Registers both hooks under UserPromptSubmit and SessionEnd with per-event idempotency
scripts/uninstall.sh Removes both hook entries and both installed scripts
settings.example.json Both hook events documented
tests/lib.sh New run_session_end() helper
tests/test_gc_sweep.sh NEW — 4 scenarios (fresh skip, stale prune, 24h rate-limit, marker survives)
tests/test_session_end.sh NEW — 3 scenarios (targeted delete, malicious session_id sanitized, silent on absent file)
README.md New "Garbage Collection" section, updated install + uninstall + hand-merge sections
CHANGELOG.md 0.2.0 entry

Impact

  • 10 files changed, +471 / -35
  • Backward-compatible (new SessionEnd registration is additive; existing UserPromptSubmit users keep working with the original behavior plus the now-active GC)
  • No new external dependencies; uses existing python3/jq/bash/find toolchain

Risk: LOW

  • Sweep is rate-limited and silent on failure
  • SessionEnd hook never globs and never touches the directory itself — only rm on a single sanitized path
  • All existing tests still pass (verified locally + CI)
  • Path-traversal protection mirrors the existing test_malicious_session_id.sh pattern

Testing

  • 9/9 tests pass on macOS-latest and ubuntu-latest (CI green: run 25457017313)
  • Local smoke tests confirmed: install/uninstall idempotency per-event; SessionEnd deletes only its target; sweep marker survives sweep; rate-limit blocks repeat sweeps within 24h

Breaking Changes

None. Users with an existing 0.1.0 install can re-run scripts/install.sh to add the SessionEnd registration; the existing UserPromptSubmit entry will be detected and skipped.

Checklist

  • All tests pass (9/9)
  • CI green on macOS + Ubuntu
  • CHANGELOG updated (0.2.0)
  • README updated (new GC section + install/uninstall edits)
  • Bash 3.2-compatible
  • No new external dependencies

🤖 Generated with Claude Code

Adds two-pronged garbage collection for the per-session state files
written under ~/.claude/state/time-inject/{SESSION_ID}.json. Without
GC, every Claude Code session leaves an orphan file and long-lived
users accumulate hundreds.

Strategy:

- SessionEnd hook (hooks/session-end.sh) — fires when a Claude Code
  session ends cleanly. Reads session_id from stdin, sanitizes via
  the same [A-Za-z0-9._-] whitelist used by inject-time-context.sh,
  and removes only that one file. Silent on failure. No kill-switch
  check (cleanup runs even when CLAUDE_TIME_INJECT=0).

- Lazy sweep (inject-time-context.sh) — backstop for sessions that
  ended dirty (hard kill, crash, OS reboot before SessionEnd fires).
  Rate-limited via the .gc-sweep marker file (one sweep per 24h).
  Prunes any session state file with mtime older than 7 days. Uses
  python3 mtime read instead of stat (avoids BSD/GNU divergence) and
  find -print0 | xargs (avoids the flag-order footgun in find).

- Liveness signal — hook now touches the active state file on every
  prompt regardless of whether an injection fires, so a long-running
  quiet session is not mistakenly pruned.

Installer + uninstaller updated to register and de-register both
hooks with per-event idempotency. settings.example.json shows both
entries.

Test coverage: 4 new GC scenarios (fresh skip, stale prune, 24h
rate-limit honored, marker survives) + 3 new SessionEnd scenarios
(targeted delete only, malicious session_id sanitized, silent on
absent file). 9/9 pass on macOS + Ubuntu.

Version bump 0.1.0 → 0.2.0 (minor — backward-compatible feature).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Copy Markdown

@ds9-tester-bot ds9-tester-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smoke test from main agent — verifying bot install on DoubleNode org

Copy link
Copy Markdown

@ds9-tester-bot ds9-tester-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA Test Report — claude-context-tick PR #1 (XACA-0341)

Tester: Lura Thok (Academy Testing Division)
Date: 2026-05-06
Branch: feature/xaca-0341-session-gc


Syntax Check

All shell files passed bash -n static syntax validation.
Exit code: 0


Test Suite

bash tests/run-tests.sh9/9 passed, 0 failed

[PASS] test_date_rollover
[PASS] test_first_run
[PASS] test_gc_sweep
[PASS] test_kill_switch
[PASS] test_malicious_session_id
[PASS] test_qh_tick
[PASS] test_session_end
[PASS] test_state_tracking
[PASS] test_tz_shift

Manual Scenario Results

All scenarios executed in isolated sandbox HOMEs with exported HOME variable to ensure clean state. Repo hooks invoked directly via bash hooks/inject-time-context.sh and bash hooks/session-end.sh.

Scenario A — Rapid injection rate-limiting (30 invocations, same session)
PASS. Exactly 1 injection fired on the first call; all 29 subsequent calls within the same QH bucket produced empty stdout. State file and .gc-sweep marker correctly created.

Scenario B — Stale marker (25h), 8-day-old session file triggers sweep
PASS. Sweep ran (marker age 90000s > 86400s threshold). The 8-day-old old-session-stale.json was removed. Marker mtime updated to current time. Fresh session file was created normally.

Scenario C — Fresh marker (mtime=now), 8-day-old session file survives (rate-limit honored)
PASS. Sweep did NOT run (marker age <10s). The 8-day-old session file survived untouched. Fresh session state written normally. Rate-limit correctly prevents sweep within 24h window.

Scenario D — session-end.sh deletes only the targeted session file
PASS. Three session files pre-seeded (session-alpha.json, session-beta.json, session-gamma.json) plus .gc-sweep marker. session-end.sh invoked with session_id: "session-beta". Result: session-beta.json removed; session-alpha.json, session-gamma.json, and .gc-sweep all survived.

Scenario E — Malicious session_id ../../../etc/passwd blocked by sanitization
PASS. The whitelist regex [^A-Za-z0-9._-] strips / and collapses the traversal to ......etcpasswd. /etc/passwd was not modified (size identical before and after). The sanitized target path (......etcpasswd.json) inside STATE_DIR did not exist and was not created. Silent exit.

Scenario F — install.sh idempotency + uninstall.sh cleanup
PASS. First install: created settings.json with both UserPromptSubmit and SessionEnd hook entries. Second install: correctly printed "Already installed — UserPromptSubmit hook entry already present." and "Already installed — SessionEnd hook entry already present." for both events. uninstall.sh --yes: removed both hook entries from settings.json; both hook scripts removed; settings verified via jq shows no remaining UserPromptSubmit or SessionEnd keys.

Scenario G — Read-only STATE_DIR mid-run: silent exit, no crash
PASS. After chmod a-w on STATE_DIR, a new-session invocation (which would need to write a new state file) produced empty stdout and empty stderr. No crash, no non-zero exit. The hook degraded gracefully as designed.

Scenario H — Malformed JSON on stdin: fallback to "unknown" session_id
PASS. Both hooks tested. inject-time-context.sh with non-JSON stdin: fell back to session_id="unknown", fired a first-run injection (correct — unknown is a valid session ID string), produced no stderr. session-end.sh with malformed JSON: fell back to "unknown", silently deleted unknown.json (which existed), no stderr. Both hooks are silent on bad input.


Summary

  • Syntax check: 0 errors
  • Test suite: 9/9 PASS
  • All 8 manual scenarios: PASS
  • No critical bugs found
  • Security sanitization verified (path traversal blocked)
  • Rate-limit, GC, idempotency, and cleanup all behave as specified
  • No stderr leakage on any error path tested

Copy link
Copy Markdown

@ai-security-review-bot ai-security-review-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #1 — XACA-0341 session GC

Decision: APPROVE (3 non-blocking subitems filed on XACA-0341)


Security

Path traversal: both inject-time-context.sh and session-end.sh sanitize session_id via the same re.sub(r'[^A-Za-z0-9._-]', '', sid) whitelist before building any filesystem path. The sanitization is verbatim-copied (not reimplemented), which is the right call. SE-2 test confirms a ../../../etc/passwd input is defused to ......etcpasswd and the resulting target stays inside STATE_DIR. No eval, no unquoted command substitution against tainted data. No secrets in any committed file. Clean.

Architecture

_gc_run_sweep is cleanly isolated: it reads only STATE_DIR (a module-level constant), calls _gc_sweep_due (its own helper), and touches only .json files in that dir. No unintended global writes. The touch-after-state-write ordering is correct: liveness signal on line 172 runs AFTER both FIRST_RUN detection (line 105) and the atomic state write (lines 135–150), so FIRST_RUN semantics are preserved. The || exit 0 escape at line 135 (mktemp failure) correctly prevents the liveness touch from running — the script exits before reaching line 172, so STATE_FILE is never pre-created. Architecture is sound.

Code Quality

  • All variable expansions in path-building contexts are properly quoted.
  • python3 calls inside _gc_sweep_due are wrapped with 2>/dev/null. The _gc_run_sweep caller wraps the entire function call with 2>/dev/null || true. The xargs pipeline ends with || true. Silent-failure pattern is consistently applied.
  • return $? after the python3 call in _gc_sweep_due is explicit and correct with set -euo pipefail. The function returns the python3 exit code (0=due, 1=not due).
  • No mapfile, no declare -A, no [[ -v ]] — Bash 3.2 compatible throughout.
  • find -print0 | xargs -0 is the correct pattern for filenames with spaces/special characters. The ! -name ".gc-sweep" exclusion is belt-and-suspenders (the marker has no .json extension so *.json already excludes it, but explicit exclusion is good defensive coding).
  • session-end.sh deliberately omits set -euo pipefail — this is intentional and correctly documented in the header ("pure cleanup, must never exit non-zero"). Clean.

Performance

Sweep is O(n) over state files but only runs inside the 24h rate-limit window. On every other prompt it's a single stat-equivalent via python3 getmtime. The liveness touch on each prompt is a single syscall. No performance concerns.

Testing — 9/9 pass

All tests confirmed passing locally. GC-1 through GC-4 and SE-1 through SE-3 cover the main spec. Three non-blocking gaps filed as [Review] subitems on XACA-0341 (see below).

Documentation

README "Garbage Collection" section accurately describes the two-pronged strategy, liveness signal, and config note. CHANGELOG 0.2.0 entry is complete. settings.example.json matches the installed format. Hand-merge example in README matches the example file.

Two stale references in the existing Troubleshooting section (not introduced by this PR, but now more visible) are filed as review subitems:

  • hooks.userPromptSubmit (camelCase) should be hooks.UserPromptSubmit (PascalCase) — the correct key since 0.1.0.
  • path field reference is leftover from the pre-0.1.0 schema; the installed format uses command inside a hooks array.

Backward Compatibility

Install idempotency is handled correctly: already_installed() and already_installed_session_end() are independent checks. A 0.1.0 user re-running install gets: UserPromptSubmit skipped (already present) + SessionEnd added. Correct behavior.


Non-Blocking Subitems Filed on XACA-0341

  1. [Review] README Troubleshooting uses stale camelCase key hooks.userPromptSubmit — should be hooks.UserPromptSubmit
  2. [Review] README Troubleshooting line 147 references a path field — the current schema uses command inside a hooks array
  3. [Review] Consider adding SE-4 test for session-end when STATE_DIR is completely absent (vs SE-3 which uses an empty dir). Manual verify confirms exit 0 + silent; minor spec gap only.

No blocking issues. The implementation is clean, well-tested, and maintains the silent-failure contract throughout.

- README Troubleshooting: fix stale schema references
  - hooks.userPromptSubmit → hooks.UserPromptSubmit (PascalCase)
  - top-level 'path' field → 'command' inside the hooks array
  Both refs date back to a pre-0.1.0 schema and were missed in the
  initial publish — caught by the PR #1 reviewer.

- tests/test_session_end.sh: add SE-4 scenario.
  Stronger than SE-3: STATE_DIR (and ~/.claude/state) is fully absent,
  not just empty. Confirms session-end.sh exits 0 silently when there
  is no state directory at all.

Resolves XACA-0341-009, XACA-0341-010, XACA-0341-011 (review subitems
filed against the parent kanban item by the reviewer subagent).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Copy Markdown

@ds9-tester-bot ds9-tester-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RE-QA APPROVED — SHA c8040d7 validated.

Diff verification: README.md corrects two stale schema references (hooks.userPromptSubmit → hooks.UserPromptSubmit; top-level path field → command inside hooks array). tests/test_session_end.sh adds SE-4 scenario (STATE_DIR completely absent). Both changes match the claimed scope exactly.

Test suite: 9/9 passed (test_date_rollover, test_first_run, test_gc_sweep, test_kill_switch, test_malicious_session_id, test_qh_tick, test_session_end, test_state_tracking, test_tz_shift). SE-4 executed and passed within test_session_end.

SE-4 manual sandbox verification: Ran session-end.sh with HOME set to a fresh temp dir containing no .claude directory. Exit code: 0. Post-run ls confirms .claude was never created.

Regression smoke tests: inject-time-context.sh correctly injects context and creates state file for fresh session_id (PASS). session-end.sh deletes only its own session file from a three-file STATE_DIR, leaving siblings untouched (PASS).

No regressions detected. PR is ready to merge.

Copy link
Copy Markdown

@ai-security-review-bot ai-security-review-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of SHA c8040d7 (follow-up commit addressing PR #1 review feedback).

Prior [Review] subitems — all properly addressed:

  1. hooks.userPromptSubmit casing: README now correctly uses hooks.UserPromptSubmit (PascalCase array). Verified matches settings.example.json and scripts/install.sh lines 95/117/155-163.

  2. Top-level path field: README now correctly describes command field inside the inner hooks array entry. Matches actual settings schema shape produced by the installer.

  3. SE-4 absent-STATE_DIR test: Added. Removes both STATE_DIR and the entire ${HOME}/.claude/state tree before exercising the hook — correctly models the "never created" scenario. Asserts exit 0 and empty stdout/stderr. Does NOT assert dir re-creation (correct — mkdir is UserPromptSubmit's job, not SessionEnd's). Style is consistent with SE-1/SE-2/SE-3 (numbered variable suffixes, comment header block, assert ordering). Full suite runs 9/9 green.

No new [Review] subitems filed. The delta is clean.

APPROVE — no blocking issues in the follow-up commit.

@ehlersd ehlersd merged commit a0557aa into main May 6, 2026
4 checks passed
@ehlersd ehlersd deleted the feature/xaca-0341-session-gc branch May 6, 2026 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant