diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eade0a..39fd81e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-05-06 + +### Added + +- Lazy garbage collection of session state files in `inject-time-context.sh`: + - Rate-limited via `${STATE_DIR}/.gc-sweep` marker (one sweep per 24h max). + - Prunes session state files with mtime older than 7 days. + - Liveness signal: `touch` of the active state file on every prompt prevents quiet long-running sessions from being expired. + - Fully silent — failures never propagate to Claude Code stderr. +- New `hooks/session-end.sh` SessionEnd hook for prompt, precise per-session cleanup. Deletes only the ending session's state file. +- `scripts/install.sh` now registers both `UserPromptSubmit` and `SessionEnd` hooks. Per-event idempotency. +- `scripts/uninstall.sh` removes both hook entries and both installed scripts. +- `settings.example.json` shows both hook registrations. +- Test coverage: `tests/test_gc_sweep.sh` (4 scenarios), `tests/test_session_end.sh` (3 scenarios). 9/9 pass on macOS + Ubuntu. + +### Changed + +- `tests/lib.sh` — added `run_session_end` helper. + ## [0.1.0] - 2026-05-06 ### Added diff --git a/README.md b/README.md index 5f56611..4fed57e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The hook injects a single-line context message `YYYY-MM-DD · HH:M - **Quarter-hour tick** — minute boundary hits 00, 15, 30, or 45 - **Timezone shift** — DST transition or laptop traveling -State is tracked per-session in `~/.claude/state/time-inject/` with atomic writes (`mktemp` + `mv`) preventing partial state corruption on abnormal exits. +State is tracked per-session in `~/.claude/state/time-inject/` with atomic writes (`mktemp` + `mv`) preventing partial state corruption on abnormal exits. The hook also performs lazy garbage collection of stale state files: files idle for ≥7 days are pruned on a 24-hour sweep cadence, and a complementary `SessionEnd` hook deletes a session's file immediately when the session ends cleanly. ## Install @@ -47,10 +47,28 @@ If you prefer to merge manually, copy the `hooks` entry from `settings.example.j ```json { "hooks": { - "userPromptSubmit": { - "path": "/path/to/claude-context-tick/hooks/inject-time-context.sh", - "enabled": true - } + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${HOME}/.claude/hooks/inject-time-context.sh" + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${HOME}/.claude/hooks/session-end.sh" + } + ] + } + ] } } ``` @@ -67,6 +85,8 @@ Environment variable to control behavior: Example: `CLAUDE_TIME_INJECT=0 claude` launches a session with no time injections. +Note: Garbage collection behavior (7-day retention, 24-hour sweep cadence) is not currently configurable. Future versions may expose these via environment variables. + ## How It Decides Whether to Inject (State-Tracking Explainer) On each prompt submission, the hook: @@ -95,13 +115,27 @@ The state file format is simple JSON: The `reason` field documents why the injection fired (useful for debugging). +## Garbage Collection + +Without automated cleanup, every Claude Code session leaves behind a state file. Over weeks or months, a long-lived developer can accumulate hundreds of idle files, wasting disk space and cluttering the state directory. + +The hook implements a two-pronged GC strategy: + +**SessionEnd hook** — When a Claude Code session ends cleanly, the `SessionEnd` hook fires and deletes only that session's state file. This is precise, prompt, and race-free: no concurrent prompts, no sweep logic, just `rm -f ~/.claude/state/time-inject/{SESSION_ID}.json`. + +**Lazy sweep** — Sessions that end abnormally (hard kill, OS crash, reboot before hook fires) leave orphan files. The `inject-time-context.sh` hook runs a backstop: once per 24 hours, it prunes any state file with mtime older than 7 days. This is rate-limited via a marker file (`${STATE_DIR}/.gc-sweep`) to avoid scanning the directory on every single prompt. + +**Liveness signal** — Every prompt submission, even when no injection fires, `touch`-updates the state file's mtime. This ensures a long-running but quiet session (stuck on a single task for 8 hours within a quarter-hour window) is not mistakenly swept away. + +**Configuration** — The retention threshold (7 days) and sweep interval (24 hours) are fixed at compile time. There are no environment variable overrides; this design choice prioritizes zero-config simplicity. If you need immediate cleanup, simply `rm -rf ~/.claude/state/time-inject/` at any time. The hook will recreate the directory on its next run. + ## Uninstall ```bash bash scripts/uninstall.sh ``` -This removes the hook entry from `~/.claude/settings.json`. The state directory `~/.claude/state/time-inject/` is left in place (can be manually removed if desired). +This removes both the `UserPromptSubmit` and `SessionEnd` hook entries from `~/.claude/settings.json`, and deletes the installed hook scripts. The state directory `~/.claude/state/time-inject/` is left in place (can be manually removed if desired). ## Troubleshooting @@ -109,8 +143,8 @@ This removes the hook entry from `~/.claude/settings.json`. The state directory The first prompt of any session should always fire an injection. Check `~/.claude/state/time-inject/` — you should see one `.json` file per session ID. If it's empty: -- Verify `~/.claude/settings.json` has the hook entry under `hooks.userPromptSubmit`. -- Confirm the `path` points to your `inject-time-context.sh` script (not a stale path). +- Verify `~/.claude/settings.json` has an entry under `hooks.UserPromptSubmit` (PascalCase, an array). +- Confirm the `command` field inside that entry's `hooks` array points to your `inject-time-context.sh` script (not a stale path). - Try the one-line install again to refresh the config. ### "Hook fires but injection is missing from the conversation" diff --git a/hooks/inject-time-context.sh b/hooks/inject-time-context.sh index fa9a962..9e5864a 100755 --- a/hooks/inject-time-context.sh +++ b/hooks/inject-time-context.sh @@ -40,6 +40,46 @@ STATE_FILE="${STATE_DIR}/${SESSION_ID}.json" mkdir -p "$STATE_DIR" 2>/dev/null || exit 0 [[ -w "$STATE_DIR" ]] || exit 0 +# --- Lazy GC sweep (runs at most once per 24h) --- +# Returns 0 if a sweep is due (marker absent or >24h old), 1 otherwise. +_gc_sweep_due() { + local marker="$1" + if [ ! -f "$marker" ]; then + return 0 + fi + python3 -c " +import os, sys, time +try: + age = time.time() - os.path.getmtime(sys.argv[1]) + sys.exit(0 if age >= 86400 else 1) +except Exception: + sys.exit(1) +" "$marker" 2>/dev/null + return $? +} + +# Enumerate and remove session state files whose mtime is >7 days old. +# Belt-and-suspenders: the glob *.json already excludes .gc-sweep (no .json +# extension), but the find pattern makes the exclusion explicit. +# Uses find -mtime +7 (floor semantics: files older than 7*24h blocks). +# Never uses find -delete (flag-order footgun per dev-team policy). +# All operations silent; function always returns 0. +_gc_run_sweep() { + local marker="${STATE_DIR}/.gc-sweep" + _gc_sweep_due "$marker" 2>/dev/null || return 0 + # Prune stale .json session files; explicitly exclude the marker and .tmp.* files + find "$STATE_DIR" -maxdepth 1 -name "*.json" \ + ! -name ".gc-sweep" \ + ! -name ".tmp.*" \ + -mtime +7 \ + -print0 2>/dev/null \ + | xargs -0 rm -f 2>/dev/null || true + # Update marker regardless of whether any files were pruned + touch "$marker" 2>/dev/null || true + return 0 +} +_gc_run_sweep 2>/dev/null || true + # --- Current values --- NOW_DATE=$(date +%Y-%m-%d) NOW_TIME=$(date +%H:%M) @@ -125,4 +165,10 @@ print(json.dumps(payload)) " "$INJECT" fi +# Liveness signal: keep state file mtime fresh so the GC sweep does not expire +# an active session. Runs even when INJECT is empty (no state change this prompt). +# MUST be after FIRST_RUN detection and after the state-write block — placing it +# before FIRST_RUN would pre-create STATE_FILE and break first-run injection. +touch "$STATE_FILE" 2>/dev/null || true + exit 0 diff --git a/hooks/session-end.sh b/hooks/session-end.sh new file mode 100755 index 0000000..85239e1 --- /dev/null +++ b/hooks/session-end.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# session-end.sh — claude-context-tick SessionEnd hook +# https://github.com/DoubleNode/claude-context-tick +# SPDX-License-Identifier: MIT +# +# Removes this session's state file when SessionEnd fires. +# Complementary to the lazy sweep in inject-time-context.sh: the sweep prunes +# files from sessions that ended without firing this hook (e.g. hard kills, +# crashes); this hook provides prompt, precise cleanup for normal exits. +# +# No kill-switch check — cleanup runs even when CLAUDE_TIME_INJECT=0. +# No set -euo pipefail — pure cleanup, must never exit non-zero. + +# --- Read session_id from stdin JSON --- +# Verbatim copy of inject-time-context.sh lines 25-33. +STDIN_JSON=$(cat) +SESSION_ID=$(printf '%s' "$STDIN_JSON" | python3 -c \ + "import sys,json,re +try: + sid = json.load(sys.stdin).get('session_id','unknown') +except Exception: + sid = 'unknown' +sid = re.sub(r'[^A-Za-z0-9._-]', '', sid) or 'unknown' +print(sid)" 2>/dev/null || echo "unknown") + +STATE_DIR="${HOME}/.claude/state/time-inject" +TARGET="${STATE_DIR}/${SESSION_ID}.json" + +rm -f "$TARGET" 2>/dev/null || true + +exit 0 diff --git a/scripts/install.sh b/scripts/install.sh index ddca8c3..2ec2c6a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # install.sh — claude-context-tick installer # -# Registers inject-time-context.sh as a UserPromptSubmit hook in -# ~/.claude/settings.json without clobbering any existing hooks. +# Registers inject-time-context.sh as a UserPromptSubmit hook and +# session-end.sh as a SessionEnd hook in ~/.claude/settings.json +# without clobbering any existing hooks. # # Usage: # bash scripts/install.sh @@ -18,6 +19,7 @@ set -euo pipefail SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" REPO_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" HOOK_SRC="$REPO_ROOT/hooks/inject-time-context.sh" +SESSION_END_SRC="$REPO_ROOT/hooks/session-end.sh" INSTALL_DIR="${CLAUDE_CONTEXT_TICK_DIR:-$HOME/.claude/hooks}" SETTINGS_FILE="$HOME/.claude/settings.json" @@ -25,6 +27,9 @@ SETTINGS_FILE="$HOME/.claude/settings.json" HOOK_NAME="inject-time-context.sh" HOOK_DEST="$INSTALL_DIR/$HOOK_NAME" +SESSION_END_NAME="session-end.sh" +SESSION_END_DEST="$INSTALL_DIR/$SESSION_END_NAME" + # --------------------------------------------------------------------------- # Pre-flight checks # --------------------------------------------------------------------------- @@ -54,6 +59,12 @@ if [ ! -f "$HOOK_SRC" ]; then exit 1 fi +if [ ! -f "$SESSION_END_SRC" ]; then + echo "ERROR: Hook source not found: $SESSION_END_SRC" >&2 + echo "Run install.sh from inside the claude-context-tick repository." >&2 + exit 1 +fi + # --------------------------------------------------------------------------- # Install hook script # --------------------------------------------------------------------------- @@ -64,15 +75,21 @@ chmod +x "$HOOK_DEST" echo "Installed hook: $HOOK_DEST" +cp "$SESSION_END_SRC" "$SESSION_END_DEST" +chmod +x "$SESSION_END_DEST" + +echo "Installed hook: $SESSION_END_DEST" + # --------------------------------------------------------------------------- # Merge into ~/.claude/settings.json # --------------------------------------------------------------------------- -# The entry we want to add (as a jq-compatible object literal). -# We pass the command path via a shell variable to avoid jq injection. +# The command paths to register. +# We pass these via shell variables to avoid jq injection. NEW_ENTRY_COMMAND="$HOOK_DEST" +SESSION_END_COMMAND="$SESSION_END_DEST" -# Idempotency filter: does an entry for this exact command already exist? +# Idempotency filter: does a UserPromptSubmit entry for this exact command already exist? already_installed() { jq -e --arg cmd "$NEW_ENTRY_COMMAND" ' .hooks.UserPromptSubmit? // [] @@ -81,12 +98,21 @@ already_installed() { ' "$SETTINGS_FILE" >/dev/null 2>&1 } +# Idempotency filter: does a SessionEnd entry for this exact command already exist? +already_installed_session_end() { + jq -e --arg cmd "$SESSION_END_COMMAND" ' + .hooks.SessionEnd? // [] + | map(.hooks // [] | map(.command) | index($cmd)) + | any(. != null) + ' "$SETTINGS_FILE" >/dev/null 2>&1 +} + if [ ! -f "$SETTINGS_FILE" ]; then - # Create minimal settings.json from scratch + # Create minimal settings.json from scratch with both hook entries echo "Settings file not found — creating: $SETTINGS_FILE" mkdir -p "$(dirname "$SETTINGS_FILE")" - jq -n --arg cmd "$NEW_ENTRY_COMMAND" '{ + jq -n --arg cmd "$NEW_ENTRY_COMMAND" --arg cmd_se "$SESSION_END_COMMAND" '{ "hooks": { "UserPromptSubmit": [ { @@ -95,6 +121,14 @@ if [ ! -f "$SETTINGS_FILE" ]; then { "type": "command", "command": $cmd } ] } + ], + "SessionEnd": [ + { + "matcher": "", + "hooks": [ + { "type": "command", "command": $cmd_se } + ] + } ] } }' > "$SETTINGS_FILE" @@ -114,10 +148,9 @@ else exit 1 fi - # Idempotency: skip if the exact command is already registered + # --- UserPromptSubmit merge (independent of SessionEnd check) --- if already_installed; then - echo "Already installed — hook entry for '$HOOK_DEST' already present in settings." - echo "Nothing changed." + echo "Already installed — UserPromptSubmit hook entry already present." else # Merge: append new entry to UserPromptSubmit array (create array/key if absent) MERGED="$(jq --arg cmd "$NEW_ENTRY_COMMAND" ' @@ -138,7 +171,33 @@ else echo "$MERGED" > "$TMPFILE" mv "$TMPFILE" "$SETTINGS_FILE" - echo "Merged hook entry into: $SETTINGS_FILE" + echo "Merged UserPromptSubmit hook entry into: $SETTINGS_FILE" + fi + + # --- SessionEnd merge (independent of UserPromptSubmit check) --- + if already_installed_session_end; then + echo "Already installed — SessionEnd hook entry already present." + else + # Merge: append new entry to SessionEnd array (create array/key if absent) + MERGED_SE="$(jq --arg cmd "$SESSION_END_COMMAND" ' + .hooks //= {} + | .hooks.SessionEnd //= [] + | .hooks.SessionEnd += [ + { + "matcher": "", + "hooks": [ + { "type": "command", "command": $cmd } + ] + } + ] + ' "$SETTINGS_FILE")" + + # Write atomically via temp file + TMPFILE_SE="$(mktemp "$SETTINGS_FILE.tmp.XXXXXX")" + echo "$MERGED_SE" > "$TMPFILE_SE" + mv "$TMPFILE_SE" "$SETTINGS_FILE" + + echo "Merged SessionEnd hook entry into: $SETTINGS_FILE" fi fi @@ -149,10 +208,12 @@ echo "" echo "========================================" echo " claude-context-tick installed" echo "========================================" -echo " Hook path : $HOOK_DEST" -echo " Settings : $SETTINGS_FILE" -echo " Kill-switch : CLAUDE_TIME_INJECT=0 (set in env to disable injection)" -echo " Verify with : ls ~/.claude/state/time-inject/ after your next prompt" +echo " Hook path : $HOOK_DEST" +echo " Session-end hook : $SESSION_END_DEST" +echo " Settings : $SETTINGS_FILE" +echo " Kill-switch : CLAUDE_TIME_INJECT=0 (set in env to disable injection)" +echo " GC retention : 7 days (sweep every 24h)" +echo " Verify with : ls ~/.claude/state/time-inject/ after your next prompt" echo "========================================" echo "" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 6b80660..72af99d 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -19,9 +19,11 @@ done SETTINGS_FILE="$HOME/.claude/settings.json" HOOK_SCRIPT="$HOME/.claude/hooks/inject-time-context.sh" +SESSION_END_SCRIPT="$HOME/.claude/hooks/session-end.sh" STATE_DIR="$HOME/.claude/state/time-inject" HOOK_MATCH_PATTERN="inject-time-context.sh" +SESSION_END_MATCH_PATTERN="session-end.sh" # --------------------------------------------------------------------------- # Pre-flight @@ -52,11 +54,12 @@ if [ "$SETTINGS_MODIFIED" -eq 1 ]; then cp "$SETTINGS_FILE" "$BACKUP" echo "Backed up settings: $BACKUP" - # Remove any UserPromptSubmit entry whose hooks[].command ends with our hook name. + # Remove any UserPromptSubmit entry whose hooks[].command ends with our hook name, + # and any SessionEnd entry whose hooks[].command ends with session-end.sh. # After removal: - # - if UserPromptSubmit array is empty → drop the key - # - if hooks object is empty → drop the key - PATCHED="$(jq --arg pat "$HOOK_MATCH_PATTERN" ' + # - if an event array is empty → drop that key + # - if hooks object is empty → drop the key + PATCHED="$(jq --arg pat "$HOOK_MATCH_PATTERN" --arg pat_se "$SESSION_END_MATCH_PATTERN" ' if .hooks.UserPromptSubmit? then .hooks.UserPromptSubmit |= map( .hooks |= map(select(.command | endswith($pat) | not)) @@ -65,11 +68,19 @@ if [ "$SETTINGS_MODIFIED" -eq 1 ]; then | if (.hooks.UserPromptSubmit | length) == 0 then del(.hooks.UserPromptSubmit) else . end - | if (.hooks | length) == 0 then - del(.hooks) - else . end - else . - end + else . end + | if .hooks.SessionEnd? then + .hooks.SessionEnd |= map( + .hooks |= map(select(.command | endswith($pat_se) | not)) + | select(.hooks | length > 0) + ) + | if (.hooks.SessionEnd | length) == 0 then + del(.hooks.SessionEnd) + else . end + else . end + | if (.hooks | length) == 0 then + del(.hooks) + else . end ' "$SETTINGS_FILE")" # Write atomically @@ -81,7 +92,7 @@ if [ "$SETTINGS_MODIFIED" -eq 1 ]; then fi # --------------------------------------------------------------------------- -# Optionally remove installed hook script +# Optionally remove installed hook scripts # --------------------------------------------------------------------------- remove_hook_script=0 @@ -104,6 +115,27 @@ else echo "Hook script not found at $HOOK_SCRIPT — skipping." fi +remove_session_end_script=0 + +if [ -f "$SESSION_END_SCRIPT" ]; then + if [ "$YES_FLAG" -eq 1 ]; then + remove_session_end_script=1 + else + read -r -p "Remove installed session-end hook script ($SESSION_END_SCRIPT)? [y/N] " reply + case "$reply" in + [Yy]*) remove_session_end_script=1 ;; + *) echo "Keeping session-end hook script." ;; + esac + fi + + if [ "$remove_session_end_script" -eq 1 ]; then + rm -f "$SESSION_END_SCRIPT" + echo "Removed: $SESSION_END_SCRIPT" + fi +else + echo "Session-end hook script not found at $SESSION_END_SCRIPT — skipping." +fi + # --------------------------------------------------------------------------- # Optionally remove state directory # --------------------------------------------------------------------------- @@ -136,10 +168,11 @@ echo "" echo "========================================" echo " claude-context-tick uninstalled" echo "========================================" -[ "$SETTINGS_MODIFIED" -eq 1 ] && echo " Settings : hook entries removed" -[ -n "${BACKUP:-}" ] && echo " Backup : $BACKUP" -[ "$remove_hook_script" -eq 1 ] && echo " Hook : removed" || echo " Hook : kept (or not found)" -[ "$remove_state" -eq 1 ] && echo " State dir : removed" || echo " State dir : kept (or not found)" +[ "$SETTINGS_MODIFIED" -eq 1 ] && echo " Settings : hook entries removed" +[ -n "${BACKUP:-}" ] && echo " Backup : $BACKUP" +[ "$remove_hook_script" -eq 1 ] && echo " Hook : removed" || echo " Hook : kept (or not found)" +[ "$remove_session_end_script" -eq 1 ] && echo " Session-end hook : removed" || echo " Session-end hook : kept (or not found)" +[ "$remove_state" -eq 1 ] && echo " State dir : removed" || echo " State dir : kept (or not found)" echo "========================================" echo "" diff --git a/settings.example.json b/settings.example.json index 0033126..318b7d5 100644 --- a/settings.example.json +++ b/settings.example.json @@ -10,6 +10,17 @@ } ] } + ], + "SessionEnd": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${HOME}/.claude/hooks/session-end.sh" + } + ] + } ] } } diff --git a/tests/lib.sh b/tests/lib.sh index 95811c0..fc8344d 100755 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -68,3 +68,14 @@ run_hook() { input_json=$(printf '{"session_id":"%s","hook_event_name":"UserPromptSubmit"}' "$sid") env "$@" bash "$hook" <<< "$input_json" } + +# Invoke the session-end hook with a given session_id. +# Usage: run_session_end +# Passes SessionEnd JSON via stdin; returns hook exit code. +run_session_end() { + local sid="$1" + local hook="${REPO_ROOT}/hooks/session-end.sh" + local input_json + input_json=$(printf '{"session_id":"%s","hook_event_name":"SessionEnd"}' "$sid") + bash "$hook" <<< "$input_json" +} diff --git a/tests/test_gc_sweep.sh b/tests/test_gc_sweep.sh new file mode 100755 index 0000000..fe2ad59 --- /dev/null +++ b/tests/test_gc_sweep.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# test_gc_sweep.sh — validate lazy GC sweep in inject-time-context.sh +# +# Covers four scenarios from design memo §6: +# GC-1: Fresh files survive sweep (mtime now). +# GC-2: Stale file (mtime 8 days ago) is removed; recent file survives. +# GC-3: Rate-limit: fresh marker → sweep skipped, stale file survives. +# GC-4: Sweep never removes the .gc-sweep marker itself. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${REPO_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +if [[ "${_RUNNER_SANDBOX:-0}" != "1" ]]; then + _TMP_HOME=$(mktemp -d) + export HOME="$_TMP_HOME" + trap 'rm -rf "$_TMP_HOME"' EXIT +fi + +source "${REPO_ROOT}/tests/lib.sh" + +STATE_DIR="${HOME}/.claude/state/time-inject" +MARKER="${STATE_DIR}/.gc-sweep" + +# Compute an 8-day-ago timestamp portably (macOS BSD + Linux GNU safe). +# touch -t requires YYYYMMDDhhmm format; python3 datetime handles both platforms. +OLD_TSTAMP=$(python3 -c " +import datetime +d = datetime.datetime.now() - datetime.timedelta(days=8) +print(d.strftime('%Y%m%d%H%M')) +") + +# Helper: stamp the sweep marker as stale (2025-01-01 00:00 — well past 24h). +_marker_stale() { + mkdir -p "$STATE_DIR" + touch -t 202501010000 "$MARKER" +} + +# Helper: stamp the sweep marker as fresh (now). +_marker_fresh() { + mkdir -p "$STATE_DIR" + touch "$MARKER" +} + +# --- GC-1: Fresh files survive a sweep ------------------------------------ +# Setup: three session files with mtime NOW; marker is absent (sweep will run). +rm -rf "$STATE_DIR" +mkdir -p "$STATE_DIR" +touch "${STATE_DIR}/session-a.json" +touch "${STATE_DIR}/session-b.json" +touch "${STATE_DIR}/session-c.json" +# No marker — sweep is due. + +run_hook "gc1-trigger-session" >/dev/null + +assert_file_exists "${STATE_DIR}/session-a.json" "GC-1: fresh file session-a.json must survive sweep" +assert_file_exists "${STATE_DIR}/session-b.json" "GC-1: fresh file session-b.json must survive sweep" +assert_file_exists "${STATE_DIR}/session-c.json" "GC-1: fresh file session-c.json must survive sweep" +assert_file_exists "$MARKER" "GC-1: sweep must create/update .gc-sweep marker" + +# --- GC-2: Stale file removed; recent file survives ----------------------- +# Setup: one old file (8 days ago), one recent file; marker is stale (sweep due). +rm -rf "$STATE_DIR" +mkdir -p "$STATE_DIR" +touch "${STATE_DIR}/session-old.json" +touch -t "$OLD_TSTAMP" "${STATE_DIR}/session-old.json" +touch "${STATE_DIR}/session-recent.json" +_marker_stale + +run_hook "gc2-trigger-session" >/dev/null + +assert_file_absent "${STATE_DIR}/session-old.json" "GC-2: stale file must be removed by sweep" +assert_file_exists "${STATE_DIR}/session-recent.json" "GC-2: recent file must survive sweep" + +# --- GC-3: Rate-limit prevents re-sweep when marker is fresh -------------- +# Setup: stale session file; marker mtime = NOW (within 24h window). +rm -rf "$STATE_DIR" +mkdir -p "$STATE_DIR" +touch "${STATE_DIR}/session-stale.json" +touch -t "$OLD_TSTAMP" "${STATE_DIR}/session-stale.json" +_marker_fresh + +run_hook "gc3-trigger-session" >/dev/null + +assert_file_exists "${STATE_DIR}/session-stale.json" "GC-3: stale file must NOT be removed when marker is fresh (rate-limit active)" + +# --- GC-4: Sweep never removes the .gc-sweep marker ---------------------- +# Setup: no session files; marker is stale so sweep will run. +rm -rf "$STATE_DIR" +mkdir -p "$STATE_DIR" +_marker_stale + +run_hook "gc4-trigger-session" >/dev/null + +assert_file_exists "$MARKER" "GC-4: .gc-sweep marker must survive the sweep" + +echo "[PASS] test_gc_sweep" diff --git a/tests/test_session_end.sh b/tests/test_session_end.sh new file mode 100755 index 0000000..fb40d1c --- /dev/null +++ b/tests/test_session_end.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# test_session_end.sh — validate session-end.sh hook behaviour +# +# Covers four scenarios: +# SE-1: SessionEnd deletes only its own session file; all other files survive. +# SE-2: Malicious session_id (path-traversal) is sanitized; real /etc/passwd untouched. +# SE-3: Empty STATE_DIR + nonexistent session_id → exit 0, no output. +# SE-4: STATE_DIR completely absent (never created) → exit 0, no output. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${REPO_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +if [[ "${_RUNNER_SANDBOX:-0}" != "1" ]]; then + _TMP_HOME=$(mktemp -d) + export HOME="$_TMP_HOME" + trap 'rm -rf "$_TMP_HOME"' EXIT +fi + +source "${REPO_ROOT}/tests/lib.sh" + +STATE_DIR="${HOME}/.claude/state/time-inject" +MARKER="${STATE_DIR}/.gc-sweep" + +# --- SE-1: Proper cleanup — deletes only its own file -------------------- +# This is the authoritative "SessionEnd fires correct cleanup" test. +# Assertion-rich: targeted file gone, all other files + marker survive, +# STATE_DIR itself survives, exit code is 0. +rm -rf "$STATE_DIR" +mkdir -p "$STATE_DIR" +touch "${STATE_DIR}/alpha.json" +touch "${STATE_DIR}/beta.json" +touch "${STATE_DIR}/gamma.json" +touch "$MARKER" + +run_session_end "alpha" +SE1_EXIT=$? + +assert_equal "$SE1_EXIT" "0" "SE-1: session-end must exit 0" +assert_file_absent "${STATE_DIR}/alpha.json" "SE-1: session-end must delete its own file (alpha.json)" +assert_file_exists "${STATE_DIR}/beta.json" "SE-1: session-end must not touch other session files (beta.json)" +assert_file_exists "${STATE_DIR}/gamma.json" "SE-1: session-end must not touch other session files (gamma.json)" +assert_file_exists "$MARKER" "SE-1: session-end must not touch the .gc-sweep marker" + +# STATE_DIR itself must still exist (hook must never rm -rf the dir). +if [[ ! -d "$STATE_DIR" ]]; then + echo "ASSERT FAILED: SE-1: session-end must not remove STATE_DIR" >&2 + echo " Expected directory to exist: $STATE_DIR" >&2 + exit 1 +fi + +# --- SE-2: Malicious session_id cannot escape STATE_DIR ------------------ +# Input: "../../../etc/passwd" +# Sanitized via [^A-Za-z0-9._-] removal → "......etcpasswd" +# Hook must delete ${STATE_DIR}/......etcpasswd.json (not /etc/passwd). +# +# Counting: "../" = ".." + "/" → keep "..", strip "/" +# Three "../" segments = 6 dots; then "etc" + "/" (stripped) + "passwd" +# Result: "......etcpasswd" +rm -rf "$STATE_DIR" +mkdir -p "$STATE_DIR" + +SANITIZED_SID=$(python3 -c "import re; print(re.sub(r'[^A-Za-z0-9._-]', '', '../../../etc/passwd') or 'unknown')") +SANITIZED_FILE="${STATE_DIR}/${SANITIZED_SID}.json" +touch "$SANITIZED_FILE" + +run_session_end "../../../etc/passwd" + +assert_file_absent "$SANITIZED_FILE" "SE-2: sanitized state file must be deleted by session-end" + +# /etc/passwd must not have been deleted or modified — it is root-owned and outside +# STATE_DIR; any path constructed from the sanitized form lives inside STATE_DIR. +# Belt-and-suspenders: verify the file we seeded was in STATE_DIR, not elsewhere. +HOME_ABS=$(python3 -c "import os; print(os.path.abspath('$HOME'))") +SANITIZED_ABS=$(python3 -c "import os; print(os.path.abspath('$SANITIZED_FILE'))") +if [[ "$SANITIZED_ABS" != "$HOME_ABS"* ]]; then + echo "ASSERT FAILED: SE-2: sanitized file path escapes sandbox HOME" >&2 + echo " Sanitized file: $SANITIZED_ABS" >&2 + echo " HOME: $HOME_ABS" >&2 + exit 1 +fi + +# --- SE-3: Silent when session file is already absent -------------------- +# Setup: empty STATE_DIR; session_id does not correspond to any file. +rm -rf "$STATE_DIR" +mkdir -p "$STATE_DIR" + +OUTPUT=$(run_session_end "nonexistent-session" 2>&1) +SE3_EXIT=$? + +assert_equal "$SE3_EXIT" "0" "SE-3: session-end must exit 0 when target file absent" +assert_equal "$OUTPUT" "" "SE-3: session-end must produce no output when target file absent" + +# --- SE-4: STATE_DIR completely absent ----------------------------------- +# Stronger than SE-3: not just an empty dir — the dir itself does not exist. +# session-end.sh must still exit 0 and produce no output. +rm -rf "$STATE_DIR" +# Also remove the parent so we are sure nothing exists below ~/.claude/state. +rm -rf "${HOME}/.claude/state" + +OUTPUT4=$(run_session_end "session-with-no-state-dir" 2>&1) +SE4_EXIT=$? + +assert_equal "$SE4_EXIT" "0" "SE-4: session-end must exit 0 when STATE_DIR is absent" +assert_equal "$OUTPUT4" "" "SE-4: session-end must produce no output when STATE_DIR is absent" + +echo "[PASS] test_session_end"