cc-cream 0.1.17 → 0.1.18

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to cc-cream are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.1.18] — 2026-05-29
8
+
9
+ ### Security
10
+ - **Status-line text is now stripped of terminal control characters before output.** Three stdin-derived fields — `model.display_name`, `session_name`, and `effort.level` — were written to the terminal verbatim on every render. Because `session_name` can be derived from conversation content (which may include untrusted material), an embedded ANSI/OSC escape sequence would have been interpreted by the terminal (window-title/clipboard rewrites via OSC, or cursor/erase sequences that spoof or hide output). `paint()` now passes every segment through a `sanitize()` pass that drops C0/C1 control bytes (incl. ESC, BEL, DEL) while preserving the tool's own color codes, which are added afterward. The bar is purely visual, so the strip is lossless.
11
+ - **A crafted `session_id` can no longer corrupt the session-state map.** `session_id` is used as an object key; values of `__proto__`/`constructor`/`prototype` are now rejected in `getSessionState`/`patchSessionState`, and reads use `Object.hasOwn`.
12
+
13
+ ### Fixed
14
+ - **Writes to `settings.json` (and the state file) are now atomic.** `install.js`, the `SessionStart` auto-setup hook, and `state.js` wrote via a direct `writeFileSync` over the live file; an interruption (crash, `ENOSPC`) mid-write could truncate `settings.json` and erase the user's permissions/hooks/plugins/MCP config — the very loss `readSettings` works to avoid. They now write a sibling temp file and `rename` it over the target (atomic within a filesystem; the temp shares the target's directory so the rename never crosses devices).
15
+ - **The plugin auto-update command now quotes the node binary path** (`exec "${nodePath}"`), so a node path containing spaces no longer breaks the status line.
16
+
17
+ ### Changed
18
+ - **The session-state map is capped at 50 entries**, evicting the least-recently-touched sessions. It previously gained one key per `session_id` and was never pruned, growing without bound.
19
+
7
20
  ## [0.1.17] — 2026-05-29
8
21
 
9
22
  ### Fixed
@@ -142,6 +155,7 @@ line and prints a colored ≤3-row bar — zero tokens, the model never sees it.
142
155
  - Supports **macOS and Linux**; Windows is a planned fast-follow.
143
156
  - Requires Claude Code **2.1.132+** (`effort` / `thinking` need 2.1.145+).
144
157
 
158
+ [0.1.18]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.17...v0.1.18
145
159
  [0.1.17]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.16...v0.1.17
146
160
  [0.1.16]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.15...v0.1.16
147
161
  [0.1.15]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.14...v0.1.15
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # cc-cream
2
2
 
3
+ [![CI](https://img.shields.io/github/actions/workflow/status/bart-turczynski/cc-cream/ci.yml?branch=main&label=CI)](https://github.com/bart-turczynski/cc-cream/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/cc-cream)](https://www.npmjs.com/package/cc-cream)
5
+ [![Socket Badge](https://socket.dev/api/badge/npm/package/cc-cream)](https://socket.dev/npm/package/cc-cream)
6
+ [![install size](https://img.shields.io/bundlephobia/minzip/cc-cream)](https://bundlephobia.com/package/cc-cream)
7
+ [![License: MIT](https://img.shields.io/npm/l/cc-cream)](https://github.com/bart-turczynski/cc-cream/blob/main/LICENSE)
8
+
3
9
  **C.R.E.A.M. — Cache Rules Everything Around Me.**
4
10
 
5
11
  A lightweight status-line tool for [Claude Code](https://claude.com/claude-code)
@@ -116,6 +122,13 @@ The installer:
116
122
  After install, Claude Code must be **trusted** for the directory (if prompted),
117
123
  and you may need to **restart** it for the bar to appear.
118
124
 
125
+ > **Pick one install method.** If you wire cc-cream via npm/manual (Options 2–3)
126
+ > and *then* install the plugin, the plugin won't take over the existing wiring —
127
+ > it points at your home copy, so `/plugin update` won't auto-update the bar. To
128
+ > switch to the auto-updating plugin, run `/cc-cream:setup` (or `cc-cream-setup
129
+ > --uninstall` first). Nothing breaks either way; it just stays on whichever
130
+ > method wired it.
131
+
119
132
  ### Uninstall
120
133
 
121
134
  Plugin users — two steps, **in this order** (Claude Code can't clean
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-cream",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Claude Code cache/context/cost status-line tool",
5
5
  "directories": {
6
6
  "doc": "docs"
package/src/install.js CHANGED
@@ -37,7 +37,7 @@ const TRUST_NOTE =
37
37
  // MODULE_NOT_FOUND on every render. "Degrade, never crash" (CLAUDE.md). `exec`
38
38
  // replaces the shell so stdin/stdout pass straight through to the renderer.
39
39
  export function autoUpdateCommand(nodePath) {
40
- return `d="$(ls -1d "\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/*/cc-cream/*/ 2>/dev/null | grep -E '/[0-9]+(\\.[0-9]+)+/$' | sort -V | tail -1)"; [ -z "$d" ] && exit 0; exec ${nodePath} "\${d}src/cc-cream.js"`;
40
+ return `d="$(ls -1d "\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/*/cc-cream/*/ 2>/dev/null | grep -E '/[0-9]+(\\.[0-9]+)+/$' | sort -V | tail -1)"; [ -z "$d" ] && exit 0; exec "${nodePath}" "\${d}src/cc-cream.js"`;
41
41
  }
42
42
 
43
43
  // `desired` is considered already installed if it matches the planned command
@@ -143,6 +143,22 @@ function destinationPath() {
143
143
  return path.join(os.homedir(), '.claude', 'cc-cream', 'cc-cream.js');
144
144
  }
145
145
 
146
+ // Write `contents` to `file` atomically: write a sibling temp file, then rename
147
+ // over the target (rename is atomic within a filesystem). settings.json holds
148
+ // the user's permissions/hooks/plugins/MCP config — a direct writeFileSync that
149
+ // is interrupted (crash, ENOSPC) could truncate it and erase all of that. The
150
+ // temp file shares the target's directory so the rename never crosses devices.
151
+ export function writeFileAtomic(file, contents) {
152
+ const tmp = `${file}.tmp-${process.pid}`;
153
+ fs.writeFileSync(tmp, contents);
154
+ try {
155
+ fs.renameSync(tmp, file);
156
+ } catch (err) {
157
+ try { fs.rmSync(tmp, { force: true }); } catch {}
158
+ throw err;
159
+ }
160
+ }
161
+
146
162
  // Read settings.json safely. A MISSING or empty file -> {} (fresh start, nothing
147
163
  // to lose). A file that exists with content but fails to parse, or parses to a
148
164
  // non-object, is REFUSED: we exit rather than overwrite and erase the user's
@@ -221,7 +237,7 @@ async function uninstall({ purge }) {
221
237
  const result = planUninstall(settings);
222
238
  for (const m of result.messages) console.log(m);
223
239
  if (result.changed) {
224
- fs.writeFileSync(file, `${JSON.stringify(result.settings, null, 2)}\n`);
240
+ writeFileAtomic(file, `${JSON.stringify(result.settings, null, 2)}\n`);
225
241
  console.log(`\nUpdated ${file}.`);
226
242
  }
227
243
 
@@ -323,7 +339,7 @@ async function main() {
323
339
  for (const m of result.messages) console.log(m);
324
340
  if (result.changed) {
325
341
  fs.mkdirSync(path.dirname(file), { recursive: true });
326
- fs.writeFileSync(file, `${JSON.stringify(result.settings, null, 2)}\n`);
342
+ writeFileAtomic(file, `${JSON.stringify(result.settings, null, 2)}\n`);
327
343
  console.log(`\nWrote ${file}.`);
328
344
  }
329
345
  }
package/src/state.js CHANGED
@@ -1,6 +1,17 @@
1
1
  import fs from 'node:fs';
2
+ import process from 'node:process';
2
3
  import { isNum, numOr } from './utils.js';
3
4
 
5
+ // Cap on retained per-session entries. The state file gains one key per
6
+ // session_id and is never otherwise pruned, so without a cap it grows without
7
+ // bound. We keep the most-recently-touched sessions (by `ts`) and drop the rest.
8
+ const MAX_SESSIONS = 50;
9
+
10
+ // session_id is used as an object key. A value of __proto__/constructor/prototype
11
+ // would mutate the object's prototype instead of storing data; reject those so a
12
+ // crafted or poisoned id can't corrupt the session map.
13
+ const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
14
+
4
15
  export function readState(stateFilePath) {
5
16
  try {
6
17
  const raw = fs.readFileSync(stateFilePath, 'utf8');
@@ -13,25 +24,45 @@ export function readState(stateFilePath) {
13
24
  }
14
25
 
15
26
  export function writeState(stateFilePath, state) {
27
+ // Atomic write: a direct writeFileSync interrupted mid-write (crash, ENOSPC)
28
+ // would truncate the state file. Write a sibling temp file, then rename over
29
+ // the target (atomic within a filesystem; the temp shares the target's dir so
30
+ // the rename never crosses devices). State is regenerable, so any failure
31
+ // degrades silently — a stateless render is fine.
32
+ const tmp = `${stateFilePath}.tmp-${process.pid}`;
16
33
  try {
17
- fs.writeFileSync(stateFilePath, JSON.stringify(state));
34
+ fs.writeFileSync(tmp, JSON.stringify(state));
35
+ fs.renameSync(tmp, stateFilePath);
18
36
  } catch {
19
- // degrade silently stateless render is fine
37
+ try { fs.rmSync(tmp, { force: true }); } catch {}
20
38
  }
21
39
  }
22
40
 
23
41
  export function getSessionState(state, sessionId) {
24
- if (!sessionId || typeof sessionId !== 'string') return null;
42
+ if (!sessionId || typeof sessionId !== 'string' || isUnsafeKey(sessionId)) return null;
25
43
  const sessions = state?.sessions;
26
44
  if (!sessions || typeof sessions !== 'object') return null;
27
- return sessions[sessionId] ?? null;
45
+ return Object.hasOwn(sessions, sessionId) ? sessions[sessionId] : null;
46
+ }
47
+
48
+ // Keep at most MAX_SESSIONS entries, evicting the lowest `ts` (oldest touched)
49
+ // first. Sessions without a numeric ts sort oldest.
50
+ function prune(sessions) {
51
+ const keys = Object.keys(sessions);
52
+ if (keys.length <= MAX_SESSIONS) return sessions;
53
+ const keep = keys
54
+ .sort((a, b) => numOr(sessions[b]?.ts, 0) - numOr(sessions[a]?.ts, 0))
55
+ .slice(0, MAX_SESSIONS);
56
+ const out = {};
57
+ for (const k of keep) out[k] = sessions[k];
58
+ return out;
28
59
  }
29
60
 
30
61
  export function patchSessionState(state, sessionId, patch) {
31
- if (!sessionId || typeof sessionId !== 'string') return state;
62
+ if (!sessionId || typeof sessionId !== 'string' || isUnsafeKey(sessionId)) return state;
32
63
  const sessions = { ...(state?.sessions ?? {}) };
33
64
  sessions[sessionId] = { ...(sessions[sessionId] ?? {}), ...patch };
34
- return { ...state, sessions };
65
+ return { ...state, sessions: prune(sessions) };
35
66
  }
36
67
 
37
68
  export function nextSessionPatch(data, prevSessionState, cfg, now) {
package/src/utils.js CHANGED
@@ -32,8 +32,19 @@ export function fmtNum(n, mode) {
32
32
  return String(n);
33
33
  }
34
34
 
35
+ // Strip C0/C1 control characters (incl. ESC, BEL, DEL) from any text bound for
36
+ // the terminal. stdin fields like session_name, model.display_name, and
37
+ // effort.level are echoed into the status line verbatim; without this, escape
38
+ // sequences smuggled into them would be interpreted by the terminal (title/OSC
39
+ // rewrites, clipboard writes, cursor moves that spoof or hide output). The bar
40
+ // is purely visual, so dropping control bytes is lossless. The tool's own ANSI
41
+ // color codes are added AFTER sanitizing, so they survive.
42
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control bytes is the intent
43
+ const sanitize = (text) => String(text).replace(/[\x00-\x1f\x7f-\x9f]/g, '');
44
+
35
45
  export function paint(text, color) {
36
- return color && ANSI[color] ? `${ANSI[color]}${text}\x1b[0m` : text;
46
+ const clean = sanitize(text);
47
+ return color && ANSI[color] ? `${ANSI[color]}${clean}\x1b[0m` : clean;
37
48
  }
38
49
 
39
50
  // 3-arg form: band(value, amber, red) — used by ttl / rate limits.