@tznthou/ccrecall 0.6.0 → 0.7.1

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.
@@ -15,6 +15,71 @@
15
15
  CCRECALL_PORT="${CCRECALL_PORT:-7749}"
16
16
  CCRECALL_EXTRACT_LOG="${CCRECALL_EXTRACT_LOG:-$HOME/.ccrecall/extract.log.jsonl}"
17
17
 
18
+ # Telemetry rows carry $PWD (added with #89), which discloses the account name
19
+ # and every project name the user works on. A log first created under a
20
+ # permissive umask lands at 0644 — world-readable on a shared machine. Both
21
+ # write paths below go through here: file permissions belong to the file, not
22
+ # to the call site, so securing only one of them secures neither — whichever
23
+ # path runs first decides the mode.
24
+ #
25
+ # Returns non-zero if the log cannot be secured, and callers then skip the
26
+ # write: losing a telemetry row is cheaper than leaking paths.
27
+ #
28
+ # Every step is silenced, the creation included: a shell redirection error
29
+ # names the full path on stderr, which is the account and project name this
30
+ # function exists to withhold. Failing quietly is the contract — the caller
31
+ # decides what, if anything, to say.
32
+ _ccrecall_secure_log() {
33
+ local f="$1"
34
+ # Created under the restrictive umask so a directory this line brings into
35
+ # existence starts at 0700. It deliberately does not touch the mode of a
36
+ # directory that is already there: CCRECALL_EXTRACT_LOG is user-configurable,
37
+ # so that directory can be one the user picked — a project root, or the
38
+ # current directory for a relative setting — and neither tightening it nor
39
+ # refusing to use it is this script's call to make. Both were tried and both
40
+ # were worse: chmod turned a project directory 0755 into 0700, and refusing
41
+ # dropped every row forever on a `umask 002` machine, silently.
42
+ #
43
+ # Existing installs therefore keep whatever mode they have. ~/.ccrecall is
44
+ # also created on the TypeScript side without a mode (src/core/database.ts,
45
+ # src/core/integrity-monitor.ts; only recall-telemetry.ts passes 0o700), and
46
+ # whichever creator runs first wins. Fixing that belongs there, not here.
47
+ (umask 077; mkdir -p "$(dirname "$f")") 2>/dev/null || return 1
48
+ # Reject a symlink. This narrows the window rather than closing it: the test
49
+ # here, the chmod below and the append that follows are separate pathname
50
+ # resolutions, and both chmod(2) and >> follow links, so an attacker who can
51
+ # write this directory can still win the race by planting between them.
52
+ # Closing it properly needs open(O_NOFOLLOW|O_APPEND) + fchmod on the one
53
+ # descriptor, which shell cannot express; the real remedy is the directory
54
+ # not being writable by anyone else in the first place.
55
+ if [ -L "$f" ]; then return 1; fi
56
+ [ -e "$f" ] || (umask 077; : >> "$f") 2>/dev/null || return 1
57
+ # And refuse anything that is not a regular file: chmod on a directory
58
+ # succeeds and leaves it non-traversable, and the append then fails open.
59
+ [ -f "$f" ] || return 1
60
+ chmod 600 "$f" 2>/dev/null || return 1
61
+ }
62
+
63
+ # The only place that appends to the telemetry log. Both callers go through
64
+ # here rather than each pairing a write with its own guard: a guard the caller
65
+ # has to remember is one a later caller forgets, and permissions belong to the
66
+ # file — a single unguarded append under a permissive umask re-creates the log
67
+ # 0644 and every other writer inherits it. Securing is not the caller's job to
68
+ # get right, so it is not the caller's job at all.
69
+ #
70
+ # Arguments after the path are forwarded verbatim to `jq -n -c`.
71
+ # Silenced like every step of the guard: the redirection is set up by the
72
+ # shell, so an open that fails here — the target replaced between the guard
73
+ # and this line, a full disk — prints the full path, the same disclosure the
74
+ # mode exists to prevent. jq's own diagnostics go with it; its arguments are
75
+ # fixed in this file rather than user input, so a jq error is a bug this
76
+ # repo's tests catch, not a condition to report at runtime.
77
+ _ccrecall_log_append() {
78
+ local f="$1"; shift
79
+ _ccrecall_secure_log "$f" || return 1
80
+ { jq -n -c "$@" >> "$f"; } 2>/dev/null
81
+ }
82
+
18
83
  # Resolve the directory containing this script (for prompt file).
19
84
  # zsh leaves BASH_SOURCE empty (the user's shell is zsh), so detect zsh and
20
85
  # use its %x prompt path; eval isolates the zsh-only ${(%):-%x} syntax from
@@ -29,10 +94,19 @@ CCRECALL_SCRIPT_DIR="$(cd "$(dirname "$_ccrecall_src")" && pwd)"
29
94
  unset _ccrecall_src
30
95
 
31
96
  ccrecall-extract() {
32
- local project_id
33
- # printf for the same reason as session_id below: zsh's echo mangles
34
- # backslash sequences, and $PWD is arbitrary user input.
35
- project_id=$(printf '%s' "$PWD" | sed 's|/|-|g')
97
+ # #89: this used to derive project_id here with `sed 's|/|-|g'`, which
98
+ # diverged from Claude Code's char-wise [^A-Za-z0-9] encoding for any cwd
99
+ # holding a space, dot, underscore or CJK — and every such session then
100
+ # skipped extraction forever with no error surfaced.
101
+ #
102
+ # We do NOT reimplement the encoding in shell. BSD sed/tr are byte-wise and
103
+ # emit three dashes per CJK character; getting it right here would mean a
104
+ # second implementation to keep in sync. Instead /session/last returns the
105
+ # id the daemon read off disk (see routes.ts) and we use that verbatim.
106
+ # It stays empty until that call lands, which is fine: every consumer of
107
+ # project_id below sits behind a valid session_id, which implies the call
108
+ # succeeded.
109
+ local project_id=""
36
110
 
37
111
  # ── Trust pre-flight ──
38
112
  # ccrecall-extract always launches claude with --dangerously-skip-permissions, which
@@ -83,6 +157,9 @@ ccrecall-extract() {
83
157
  # skipping extraction for ~1 in 5 sessions (any title with a newline,
84
158
  # e.g. every cmux "/model" opener). Root-caused 2026-07-16.
85
159
  session_id=$(printf '%s' "$session_meta" | jq -r '.sessionId // empty' 2>/dev/null)
160
+ # #89: authoritative project id, read off the ~/.claude/projects/ directory
161
+ # name by the indexer rather than re-derived from $PWD here.
162
+ project_id=$(printf '%s' "$session_meta" | jq -r '.projectId // empty' 2>/dev/null)
86
163
  fi
87
164
 
88
165
  # Load the structured prompt
@@ -170,14 +247,17 @@ ${prompt}"
170
247
  # more and usually saved nothing). A missed session is cheaper than that.
171
248
  if [[ "$transcript_mode" != "jsonl" ]]; then
172
249
  printf '\n⚠️ ccRecall: no text transcript (%s) — skipping extraction.\n' "$skip_reason"
173
- mkdir -p "$(dirname "$CCRECALL_EXTRACT_LOG")"
174
- jq -n -c \
250
+ # cwd, not just projectId: on a skip the daemon lookup may never have
251
+ # landed, leaving projectId empty. The raw cwd is the one fact always
252
+ # available, and #89 was diagnosed precisely by comparing it against the
253
+ # directory names under ~/.claude/projects/.
254
+ _ccrecall_log_append "$CCRECALL_EXTRACT_LOG" \
175
255
  --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
176
256
  --arg sid "$session_id" \
177
257
  --arg pid "$project_id" \
258
+ --arg cwd "$PWD" \
178
259
  --arg reason "$skip_reason" \
179
- '{ts:$ts,sessionId:$sid,projectId:$pid,mode:"skip",reason:$reason,exitCode:null,durationSec:0}' \
180
- >> "$CCRECALL_EXTRACT_LOG"
260
+ '{ts:$ts,sessionId:$sid,projectId:$pid,cwd:$cwd,mode:"skip",reason:$reason,exitCode:null,durationSec:0}' || :
181
261
  return $claude_exit
182
262
  fi
183
263
 
@@ -223,7 +303,18 @@ ${prompt}"
223
303
  # command before it).
224
304
  local log_dir
225
305
  log_dir=$(dirname "$CCRECALL_EXTRACT_LOG")
226
- mkdir -p "$log_dir"
306
+ # Same umask as the log guard uses. This line runs ~95 lines before the
307
+ # telemetry append and creates the directory on every non-skip session, so
308
+ # without it the guard's own mkdir is a no-op here and the two paths through
309
+ # this function would leave the directory at different modes — 0700 when a
310
+ # skip returns early, 0755 otherwise.
311
+ #
312
+ # Silenced and non-fatal for the same reasons as everything else that
313
+ # touches this path: a failure prints $log_dir, and a bare command is not
314
+ # exempt from errexit. Ignoring the failure is safe — mktemp below degrades
315
+ # to /dev/null on its own, and the log guard re-creates the directory when
316
+ # the telemetry row is written.
317
+ (umask 077; mkdir -p "$log_dir") 2>/dev/null || :
227
318
  find "$log_dir" -maxdepth 1 -name 'extract-stdout.*' -mmin +60 -delete 2>/dev/null
228
319
  # Trailing Xs are mandatory: BSD/macOS mktemp only substitutes Xs at the END
229
320
  # of the template — an inner XXXXXX (e.g. a .tmp suffix after it) is taken
@@ -309,7 +400,16 @@ ${prompt}"
309
400
  # rather than invoking — splits exit-0 zero-write rows into silent miss vs
310
401
  # genuine no-op (#75). Absent on rows written before this marker shipped, so
311
402
  # analysis must treat missing as unknown, not as 0.
312
- jq -n -c \
403
+ # Secured even though this row carries no cwd: the mode belongs to the file,
404
+ # and this path can be the one that creates it.
405
+ #
406
+ # `|| :` on both call sites is load-bearing under `set -e` / `setopt
407
+ # err_exit`. The previous shape put the guard in an `if` condition, which
408
+ # errexit exempts; a bare command is not exempt, so a dropped telemetry row
409
+ # would abort the function before `return $claude_exit` and hand the caller
410
+ # 1 instead of Claude's real exit status — in an interactive shell with
411
+ # errexit set, it would close the shell.
412
+ _ccrecall_log_append "$CCRECALL_EXTRACT_LOG" \
313
413
  --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
314
414
  --arg sid "$session_id" \
315
415
  --arg pid "$project_id" \
@@ -318,8 +418,7 @@ ${prompt}"
318
418
  --argjson exit "$extract_exit" \
319
419
  --argjson dur "$extract_duration" \
320
420
  --argjson textSaves "$recall_save_text_count" \
321
- '{ts:$ts,sessionId:$sid,projectId:$pid,mode:"jsonl",exitCode:$exit,durationSec:$dur,stderr:$stderr,stdoutMarker:$marker,recallSaveTextCount:$textSaves}' \
322
- >> "$CCRECALL_EXTRACT_LOG"
421
+ '{ts:$ts,sessionId:$sid,projectId:$pid,mode:"jsonl",exitCode:$exit,durationSec:$dur,stderr:$stderr,stdoutMarker:$marker,recallSaveTextCount:$textSaves}' || :
323
422
 
324
423
  return $claude_exit
325
424
  }
@@ -28,16 +28,41 @@ SELECT
28
28
  FROM injection_log;
29
29
 
30
30
  -- ── Coverage ───────────────────────────────────────────────────────
31
+ -- Broken out per source. The done-criteria count *startup* injections, and
32
+ -- v0.6.0 added a second writer to this table (`prompt` — mid-conversation
33
+ -- recall), so an unfiltered COUNT(DISTINCT memory_id) mixes the two. Read the
34
+ -- startup row. The rows do not sum: a memory injected by two sources is
35
+ -- distinct within each.
31
36
 
32
- SELECT '── COVERAGE ──' AS '';
37
+ SELECT '── COVERAGE BY SOURCE (lifetime) ──' AS '';
33
38
 
34
39
  SELECT
40
+ source,
35
41
  MIN(injected_at) AS first,
36
42
  MAX(injected_at) AS last,
37
43
  CAST(julianday(MAX(injected_at)) - julianday(MIN(injected_at)) AS INTEGER) AS span_days,
44
+ COUNT(*) AS injections,
38
45
  COUNT(DISTINCT memory_id) AS distinct_memories,
39
46
  COUNT(DISTINCT session_id) AS distinct_sessions
40
- FROM injection_log;
47
+ FROM injection_log
48
+ GROUP BY source
49
+ ORDER BY injections DESC;
50
+
51
+ -- Criterion 2 is scoped to a 7-day window; the lifetime figures above span the
52
+ -- whole table and read far higher. Rolling window rather than hardcoded dates,
53
+ -- so the report stays usable past the verdict.
54
+
55
+ SELECT '── COVERAGE BY SOURCE (last 7 days) ──' AS '';
56
+
57
+ SELECT
58
+ source,
59
+ COUNT(*) AS injections,
60
+ COUNT(DISTINCT memory_id) AS distinct_memories,
61
+ COUNT(DISTINCT session_id) AS distinct_sessions
62
+ FROM injection_log
63
+ WHERE injected_at >= datetime('now', '-7 days')
64
+ GROUP BY source
65
+ ORDER BY injections DESC;
41
66
 
42
67
  -- ── Overlap distribution ───────────────────────────────────────────
43
68