From d50399a50355259046fe218493093dff6e46800c Mon Sep 17 00:00:00 2001 From: Koen van der Heide Date: Fri, 5 Jun 2026 22:29:26 +0200 Subject: [PATCH 1/3] reject shell-code grep lines in handoff path extraction A grep context line carrying shell code (e.g. SKILL="$DIR/x.sh") was captured whole as a handoff file path. A resolved path token never contains quote/$/=/redirect/glob chars; reject candidates that do. Regression test T-56. Co-Authored-By: Claude Opus 4.8 (1M context) --- hooks/update-handoff.sh | 6 ++++++ test/run-tests.sh | 24 ++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/hooks/update-handoff.sh b/hooks/update-handoff.sh index 7e7a05f..86ba5a1 100644 --- a/hooks/update-handoff.sh +++ b/hooks/update-handoff.sh @@ -193,6 +193,10 @@ for entry in reversed(parsed): # shaped lines. Walk newest-first parity with Tier-A; extract path TOKEN (not # whole line) so "src/foo.ts:12:match" becomes "src/foo.ts"; handle Windows drive letters. +# A resolved path token never carries shell/code punctuation. Without this, +# grep CONTEXT lines like '357-SKILL="$DIR/skills/x.sh"' slip through as "paths". +_PATH_TOKEN_JUNK = set('"' + "'" + '`' + '=$<>|*?') + def first_path_token(line): if not line: return None @@ -209,6 +213,8 @@ def first_path_token(line): last_sep = max(candidate.rfind('/'), candidate.rfind('\\')) tail = candidate[last_sep+1:] if '.' in tail and len(tail) > 1: + if any(ch in _PATH_TOKEN_JUNK for ch in candidate): + return None return candidate return None diff --git a/test/run-tests.sh b/test/run-tests.sh index abda18b..f2f0e7d 100644 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -91,7 +91,7 @@ fi # After T6: EXPECTED_PASS=87, SKIPPED=39 (T6 tests not Stop-dep) # After T7: EXPECTED_PASS=88, SKIPPED=39 (T7 test not Stop-dep) # PR-comment fix: +3 Stop-dep (T-32cap +1 short-still-captured, T-32prior +2) -EXPECTED_PASS=127 +EXPECTED_PASS=129 SKIPPED=0 run_hook() { @@ -731,8 +731,28 @@ HANDOFF=$(to_native "$CACHE/handoff-s55.json") assert_eq "T-55: prior real request kept" "yes" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any('real prior intent' in q for q in d['recent_user_requests']) else 'no')")" assert_eq "T-55: prior injected purged" "no" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any('task-notification' in q for q in d['recent_user_requests']) else 'no')")" +# T-56: Tier-B grep-result extraction rejects shell/code lines. Regression: a grep +# CONTEXT line "357-SKILL=\"\$DIR/../skills/prep-compact/SKILL.md\"" was captured as a path. +cleanup +JUNK_LINE='357-SKILL="$SCRIPT_DIR/../skills/prep-compact/SKILL.md"' \ +REAL_LINE='src/real/keep.ts:42:SKILL' \ +FIX_ENV="$FIX" "$PY" -c " +import json, os +content = os.environ['JUNK_LINE'] + '\n' + os.environ['REAL_LINE'] + '\n' +turns = [ + {'message':{'role':'assistant','content':[{'type':'tool_use','id':'gj1','name':'Grep','input':{'pattern':'SKILL','output_mode':'content'}}],'usage':{'input_tokens':100,'cache_creation_input_tokens':1000,'cache_read_input_tokens':0}}}, + {'message':{'role':'user','content':[{'type':'tool_result','tool_use_id':'gj1','content':content}]}}, +] +with open(os.environ['FIX_ENV']+'/t56.jsonl','w') as f: + for t in turns: f.write(json.dumps(t)+'\n') +" +run_stop_hook '{"session_id":"s56","transcript_path":"'"$FIX/t56.jsonl"'","cwd":"/sample","permission_mode":"default","hook_event_name":"Stop"}' >/dev/null +HANDOFF=$(to_native "$CACHE/handoff-s56.json") +assert_eq "T-56: real grep path kept" "yes" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any('src/real/keep.ts' in p for p in d['recent_files']) else 'no')")" +assert_eq "T-56: shell-junk line rejected" "no" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any(('SCRIPT_DIR' in p) or ('SKILL=' in p) or ('prep-compact/SKILL.md' in p) for p in d['recent_files']) else 'no')")" + else - SKIPPED=56 + SKIPPED=58 fi # STOP_FIXTURE_OK # =================================================================== From f1b676518c48981e78a5c37cde83f3c5287d7ece Mon Sep 17 00:00:00 2001 From: Koen van der Heide Date: Fri, 5 Jun 2026 22:32:12 +0200 Subject: [PATCH 2/3] bump version to 3.0.2 Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d35b782..4e03998 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "prep-compact", "description": "When your Claude Code session's real token count crosses a configurable threshold (default 450K), an informational reminder names the warm handoff file. A Stop hook continuously maintains an on-disk handoff JSON (cumulative file paths, in-progress todos, active subagent launches, recent user-message quotes). The /prep-compact skill reads this warm handoff and adds an analytical layer (decisions, constraints, blockers, verb-anchored next-step) to emit a tailored /compact block.", - "version": "3.0.1", + "version": "3.0.2", "author": { "name": "Koen van der Heide", "url": "https://github.com/koenvdheide" diff --git a/CHANGELOG.md b/CHANGELOG.md index f18b3b3..eeeb800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to prep-compact will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.2] - 2026-06-05 + +### Fixed + +- The Stop hook no longer records grep/glob result lines that carry shell code (e.g. a context line `SKILL="$DIR/x.sh"`) as file paths in the handoff. Only clean path tokens are captured. + ## [3.0.1] - 2026-06-05 ### Fixed From c8087ed78e796f36920b6c0f511821961ec09cca Mon Sep 17 00:00:00 2001 From: Koen van der Heide Date: Fri, 5 Jun 2026 22:48:50 +0200 Subject: [PATCH 3/3] narrow path-token denylist to preserve valid filename punctuation Codex PR review: = and the single-quote char are valid in real filenames (e.g. a=b.json); the blanket denylist over-dropped them. Keep only quote/backtick/$/redirect/glob, which still catch the observed shell-junk. T-56 now also asserts a real =-named path is kept. Co-Authored-By: Claude Opus 4.8 (1M context) --- hooks/update-handoff.sh | 8 +++++--- test/run-tests.sh | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/hooks/update-handoff.sh b/hooks/update-handoff.sh index 86ba5a1..996ffcc 100644 --- a/hooks/update-handoff.sh +++ b/hooks/update-handoff.sh @@ -193,9 +193,11 @@ for entry in reversed(parsed): # shaped lines. Walk newest-first parity with Tier-A; extract path TOKEN (not # whole line) so "src/foo.ts:12:match" becomes "src/foo.ts"; handle Windows drive letters. -# A resolved path token never carries shell/code punctuation. Without this, -# grep CONTEXT lines like '357-SKILL="$DIR/skills/x.sh"' slip through as "paths". -_PATH_TOKEN_JUNK = set('"' + "'" + '`' + '=$<>|*?') +# A resolved path token never carries quote/$/backtick/redirect/glob punctuation. +# Without this, grep CONTEXT lines like '357-SKILL="$DIR/skills/x.sh"' slip through +# as "paths". '=' and "'" are intentionally NOT rejected -- they occur in real +# filenames (a=b.json, John's-note.md) and the set below still catches the junk. +_PATH_TOKEN_JUNK = set('"' + '`' + '$<>|*?') def first_path_token(line): if not line: diff --git a/test/run-tests.sh b/test/run-tests.sh index f2f0e7d..6001ad9 100644 --- a/test/run-tests.sh +++ b/test/run-tests.sh @@ -91,7 +91,7 @@ fi # After T6: EXPECTED_PASS=87, SKIPPED=39 (T6 tests not Stop-dep) # After T7: EXPECTED_PASS=88, SKIPPED=39 (T7 test not Stop-dep) # PR-comment fix: +3 Stop-dep (T-32cap +1 short-still-captured, T-32prior +2) -EXPECTED_PASS=129 +EXPECTED_PASS=130 SKIPPED=0 run_hook() { @@ -736,9 +736,10 @@ assert_eq "T-55: prior injected purged" "no" "$("$PY" -c "import json;d=json. cleanup JUNK_LINE='357-SKILL="$SCRIPT_DIR/../skills/prep-compact/SKILL.md"' \ REAL_LINE='src/real/keep.ts:42:SKILL' \ +LEGIT_LINE='fixtures/a=b.json:1:match' \ FIX_ENV="$FIX" "$PY" -c " import json, os -content = os.environ['JUNK_LINE'] + '\n' + os.environ['REAL_LINE'] + '\n' +content = os.environ['JUNK_LINE'] + '\n' + os.environ['REAL_LINE'] + '\n' + os.environ['LEGIT_LINE'] + '\n' turns = [ {'message':{'role':'assistant','content':[{'type':'tool_use','id':'gj1','name':'Grep','input':{'pattern':'SKILL','output_mode':'content'}}],'usage':{'input_tokens':100,'cache_creation_input_tokens':1000,'cache_read_input_tokens':0}}}, {'message':{'role':'user','content':[{'type':'tool_result','tool_use_id':'gj1','content':content}]}}, @@ -749,10 +750,11 @@ with open(os.environ['FIX_ENV']+'/t56.jsonl','w') as f: run_stop_hook '{"session_id":"s56","transcript_path":"'"$FIX/t56.jsonl"'","cwd":"/sample","permission_mode":"default","hook_event_name":"Stop"}' >/dev/null HANDOFF=$(to_native "$CACHE/handoff-s56.json") assert_eq "T-56: real grep path kept" "yes" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any('src/real/keep.ts' in p for p in d['recent_files']) else 'no')")" +assert_eq "T-56: legit =-char path kept" "yes" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any('a=b.json' in p for p in d['recent_files']) else 'no')")" assert_eq "T-56: shell-junk line rejected" "no" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any(('SCRIPT_DIR' in p) or ('SKILL=' in p) or ('prep-compact/SKILL.md' in p) for p in d['recent_files']) else 'no')")" else - SKIPPED=58 + SKIPPED=59 fi # STOP_FIXTURE_OK # ===================================================================