claude-rpc 0.7.2 → 0.7.4

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/README.md CHANGED
@@ -43,6 +43,8 @@ claude-rpc start
43
43
 
44
44
  That's the whole pitch. Open Claude Code in any project — the daemon picks it up within a second. Something looks wrong? `claude-rpc doctor`.
45
45
 
46
+ > `setup` registers a Windows startup entry and wires hooks into Claude Code's `settings.json`, and the daemon reports anonymous totals by default. All of it is reversible (`claude-rpc uninstall`, `community off`) and fully documented in [`SECURITY.md`](SECURITY.md) — read it first if you want to know exactly what runs.
47
+
46
48
  The Discord *desktop* app must be running. The browser client doesn't expose the local IPC bridge that Rich Presence uses.
47
49
 
48
50
  <details>
@@ -142,6 +144,8 @@ claude-rpc community report # one-shot manual flush (testing)
142
144
 
143
145
  Each report sends only: a `sessionsDelta`, a `tokensDelta`, the claude-rpc version, OS family (`linux`/`darwin`/`win32`), and the anonymous UUID v4. No prompts, paths, models, repos, costs, usernames, or hostnames — the Worker's [`validateReport`](worker/src/index.js) is the schema of record. The full Worker source is in this repo so the privacy claim is auditable.
144
146
 
147
+ For a complete account of the sensitive things claude-rpc does — startup persistence, hook injection, every outbound request, and the exact telemetry payload — see [`SECURITY.md`](SECURITY.md). It's also the reference for supply-chain scanner findings (Socket.dev et al.): the flagged persistence and hook-injection behaviors are inherent to the tool and documented there.
148
+
145
149
  ## three pieces, glued by json files
146
150
 
147
151
  ```
package/SECURITY.md ADDED
@@ -0,0 +1,169 @@
1
+ # Security & behavior disclosure
2
+
3
+ claude-rpc is a Discord Rich Presence daemon for Claude Code. To do its job it
4
+ has to (a) register itself to start on login and (b) wire commands into Claude
5
+ Code's hook system. Both are legitimate, documented behaviors — but they are
6
+ also patterns that automated supply-chain scanners (e.g. Socket.dev) flag,
7
+ because malware persists and injects the same way. This document is the
8
+ audit trail: every sensitive thing the package does, where in the source it
9
+ lives, why it's there, its blast radius, and how to reverse or disable it.
10
+
11
+ Everything below is verifiable against the published source — there is no
12
+ minified bundle, no obfuscation, no `eval`/`new Function`, and no remote code
13
+ fetch-and-execute anywhere in `src/`.
14
+
15
+ ## TL;DR for reviewers
16
+
17
+ | Behavior | Where | Scope | Reversible? |
18
+ | --- | --- | --- | --- |
19
+ | Startup persistence | `src/install.js` → `addStartupEntry` | `HKCU` Run key, current user, no admin | Yes — `claude-rpc uninstall` / `removeStartupEntry` |
20
+ | Hook injection | `src/install.js` → `installHooks` | Only into Claude Code's own `settings.json`, only our own commands | Yes — `uninstallHooks` removes exactly what it added |
21
+ | Outbound network | `src/community.js`, `src/gist.js`, `default-config.js` asset URLs | Anonymous counters + (opt-in) gist publish + GIF assets | Telemetry: `community off`. Gist: only on explicit `badge --gist`. |
22
+ | Local subprocess | `reg.exe`, `git`, `gh` | Static args, no shell interpolation of untrusted input | n/a |
23
+
24
+ No credential access, no filesystem scanning outside `~/.claude-rpc` and Claude
25
+ Code transcripts, no keylogging, no clipboard access, no AV/EDR evasion.
26
+
27
+ ## 1. Startup persistence (Windows Run key)
28
+
29
+ **Source:** `src/install.js`, `addStartupEntry` / `removeStartupEntry`.
30
+
31
+ On Windows, `setup` writes one value:
32
+
33
+ ```
34
+ HKCU\Software\Microsoft\Windows\CurrentVersion\Run
35
+ ClaudeRPC = "<path-to-exe>" daemon
36
+ ```
37
+
38
+ - **Why:** the Discord presence is driven by a long-lived daemon. If it doesn't
39
+ restart on login, your presence silently stops working after every reboot.
40
+ - **Scope:** `HKCU` (current user) only. No admin elevation, no `HKLM`, no
41
+ service install, no scheduled task.
42
+ - **Reverse it:** `claude-rpc uninstall` deletes the value. You can also delete
43
+ it by hand in `regedit` or with
44
+ `reg delete "HKCU\...\Run" /v ClaudeRPC /f`.
45
+ - The value points at the canonical install path (`%LOCALAPPDATA%`-class dir),
46
+ not at wherever you happened to run the installer from — see
47
+ `ensureCanonicalExe`.
48
+
49
+ Non-Windows platforms get **no** persistence registration (`install()` warns
50
+ and skips it).
51
+
52
+ ## 2. Hook injection into Claude Code
53
+
54
+ **Source:** `src/install.js`, `installHooks` / `uninstallHooks`.
55
+
56
+ `setup` adds command hooks to Claude Code's `settings.json` for eight lifecycle
57
+ events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
58
+ `Stop`, `SubagentStop`, `Notification`, `SessionEnd`. Each entry looks like:
59
+
60
+ ```jsonc
61
+ { "matcher": "", "hooks": [{ "type": "command", "command": "\"<exe>\" hook PostToolUse" }] }
62
+ ```
63
+
64
+ - **Why:** this *is* the integration. Claude Code's hook system is the
65
+ supported, documented way for a tool to observe session lifecycle. The hook
66
+ reads the event JSON on stdin, updates `~/.claude-rpc/state.json`, and prints
67
+ `{"continue":true}`. It never blocks, rewrites, or vetoes a tool call — see
68
+ `src/hook.js`, `processHookEvent`.
69
+ - **What the hook reads:** tool name, file path of the active tool, token usage
70
+ counters, and `git push`/`git commit` detection (for the "just shipped"
71
+ card). It writes only to local state/log files. It does not read file
72
+ *contents*, prompts, or responses beyond the usage counters Claude provides.
73
+ - **Scope:** the installer only ever touches entries whose command matches
74
+ `isOurHookCommand` (contains `claude-rpc` or `hook.js`). It will not modify,
75
+ reorder, or delete anyone else's hooks.
76
+ - **Reverse it:** `claude-rpc uninstall` (or `uninstallHooks`) strips exactly
77
+ the entries it added and leaves the rest of `settings.json` intact.
78
+
79
+ ## 3. Outbound network
80
+
81
+ There are three distinct network behaviors. Two are optional; one is cosmetic.
82
+
83
+ ### 3a. Community totals (telemetry) — ON by default for fresh installs
84
+
85
+ **Source:** `src/community.js`; endpoint in `src/default-config.js`
86
+ (`community.endpoint`); receiving end is the full Worker source in
87
+ [`worker/src/index.js`](worker/src/index.js).
88
+
89
+ A fresh install mints an anonymous UUID v4 and the daemon POSTs to
90
+ `https://claude-rpc-totals.claude-rpc.workers.dev/report` every 30 minutes.
91
+ The **complete** payload (see `buildPayload`, enforced by the Worker's
92
+ `validateReport`) is:
93
+
94
+ ```json
95
+ {
96
+ "instanceId": "<random UUID v4>",
97
+ "sessionsDelta": 3,
98
+ "tokensDelta": 142000,
99
+ "version": "0.7.3",
100
+ "osFamily": "win32",
101
+ "ts": 1716500000000
102
+ }
103
+ ```
104
+
105
+ What is **not** sent, ever: prompts, responses, file paths, file contents,
106
+ project/repo names, models, cost figures, usernames, hostnames, IP (beyond what
107
+ any HTTP request inherently exposes to Cloudflare's edge), or absolute counter
108
+ values — only forward deltas since the last accepted report.
109
+
110
+ - **Opt out any time:** `claude-rpc community off`.
111
+ - **Upgraders are protected:** anyone upgrading from a pre-v0.7 config is
112
+ written `community.enabled: false`; re-enabling requires the explicit consent
113
+ flow `claude-rpc community on`, which prints the payload schema first.
114
+ - **Why on by default:** the live badges in the README aggregate these counters.
115
+ The trade-off is disclosed here, in the README, and at install time.
116
+ - **Auditable:** the Worker persists only two running integers and a 30-day
117
+ `seen:<instanceId>` dedup marker. The source is in this repo.
118
+
119
+ ### 3b. Gist badge publishing — only on explicit command
120
+
121
+ **Source:** `src/gist.js`. Runs **only** when you run `claude-rpc badge --gist`.
122
+ Publishes a badge SVG to *your own* GitHub gist via the `gh` CLI or a
123
+ `GH_TOKEN` you supply with `gist` scope. Hits `api.github.com` /
124
+ `gist.github.com`. Never runs unattended, never on install, never from the
125
+ daemon.
126
+
127
+ ### 3c. Presence GIF assets — Discord-side only
128
+
129
+ `default-config.js` references `https://cdn.qualit.ly/clawd-*.gif`. These URLs
130
+ are handed to Discord as image keys; **Discord's** client fetches them to render
131
+ the card. The daemon itself doesn't download them. Swap them for your own URLs
132
+ in `config.json` if you prefer.
133
+
134
+ ## 4. Local subprocesses
135
+
136
+ All `child_process` use is static-argument and visible:
137
+
138
+ - `reg.exe add/delete` — the Run key above (`src/install.js`).
139
+ - `git` — read last commit subject / branch for the "just shipped" card
140
+ (`src/git.js`).
141
+ - `gh repo view --json isPrivate` — auto-hide GitHub-private repos from the card
142
+ (`src/privacy.js`); 1.5s timeout, silent skip if `gh` is absent.
143
+ - `gh gist` — only under 3b above.
144
+
145
+ No subprocess interpolates untrusted/remote input into a shell. The one
146
+ `shell: true` call (`verifyHookPipe`) uses only static, trusted args and is
147
+ documented inline as such.
148
+
149
+ ## 5. What it stores locally
150
+
151
+ Under `~/.claude-rpc/` (a.k.a. `%APPDATA%\claude-rpc\` on Windows):
152
+ `config.json`, `state.json`, `aggregate.json`, `events.jsonl` (rotated at 5 MB),
153
+ `private-list.json`, `community-cursor.json`. The scanner also reads Claude Code
154
+ transcript files to build aggregates. None of this leaves your machine except
155
+ the minimal telemetry in 3a.
156
+
157
+ ## 6. Privacy controls
158
+
159
+ Independent of telemetry, the Discord card has a privacy valve (`src/privacy.js`):
160
+ per-project `.claude-rpc.json`, a runtime private-list, config glob patterns, and
161
+ `gh`-based auto-hide of private repos. Levels: `public` / `name-only` / `hidden`.
162
+ Local dashboards and aggregates are never redacted — privacy is a one-way valve
163
+ from local state to Discord only.
164
+
165
+ ## Reporting a vulnerability
166
+
167
+ Open an issue at https://github.com/rar-file/claude-rpc/issues, or for anything
168
+ sensitive email c.archer.simmons@gmail.com. Please include version
169
+ (`claude-rpc --version`), OS, and repro steps.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,6 +57,7 @@
57
57
  "src",
58
58
  "config.example.json",
59
59
  "LICENSE",
60
- "README.md"
60
+ "README.md",
61
+ "SECURITY.md"
61
62
  ]
62
63
  }
package/src/doctor.js CHANGED
@@ -228,19 +228,30 @@ function checkDaemonLog() {
228
228
  }
229
229
  const ageMin = (Date.now() - st.mtimeMs) / 60_000;
230
230
  const sizeKB = (st.size / 1024).toFixed(1);
231
- // Look for "Discord RPC connected" in the tail to confirm Discord IPC.
232
- let connected = false;
231
+ // Infer live IPC state from the log. The daemon connects once and stays
232
+ // connected without re-logging, so grepping only for "connected" produces
233
+ // a false warning on a long-lived daemon. Instead: "Discord RPC connected",
234
+ // "Presence updated", and "Presence cleared" all imply a live connection —
235
+ // the latter two only log *after* the daemon's `connected` guard passes and
236
+ // a setActivity/clearActivity succeeds (see daemon.js). A later "retry in
237
+ // Ns" / "login failed" / "disconnected" line means it dropped. Whichever
238
+ // happened most recently wins.
239
+ let ipc = 'unknown';
233
240
  try {
234
- const tail = readFileSync(LOG_PATH, 'utf8').split('\n').slice(-50).join('\n');
235
- connected = /Discord RPC connected/i.test(tail);
236
- } catch { /* log unreadable connected stays false, warn check renders */ }
237
- if (connected) {
241
+ for (const line of readFileSync(LOG_PATH, 'utf8').split('\n').slice(-80)) {
242
+ if (/Discord RPC connected|Presence updated|Presence cleared/i.test(line)) ipc = 'up';
243
+ else if (/retry in \d+s|login failed|Discord disconnected/i.test(line)) ipc = 'down';
244
+ }
245
+ } catch { /* log unreadable — ipc stays 'unknown', warn renders */ }
246
+ if (ipc === 'up') {
238
247
  check('discord IPC connection', 'pass',
239
- `${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
240
- } else {
241
- check('discord IPC connection', 'warn',
242
- `log shows no recent "connected" line`,
248
+ `connected · ${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
249
+ } else if (ipc === 'down') {
250
+ check('discord IPC connection', 'warn', 'daemon is reconnecting to Discord',
243
251
  'is the discord desktop client running? rpc only works via desktop, not browser');
252
+ } else {
253
+ check('discord IPC connection', 'warn', 'no connection activity in the log yet',
254
+ 'start the daemon with discord desktop running');
244
255
  }
245
256
  }
246
257
 
package/src/privacy.js CHANGED
@@ -25,7 +25,10 @@ import { execFileSync } from 'node:child_process';
25
25
  import { join, basename, dirname, resolve as resolvePath } from 'node:path';
26
26
  import { DATA_DIR } from './paths.js';
27
27
 
28
- const PRIVATE_LIST_PATH = join(DATA_DIR, 'private-list.json');
28
+ // CLAUDE_RPC_PRIVATE_LIST lets tests point the runtime store at a temp file
29
+ // instead of the user's real ~/.claude-rpc/private-list.json. Unset in normal
30
+ // operation.
31
+ const PRIVATE_LIST_PATH = process.env.CLAUDE_RPC_PRIVATE_LIST || join(DATA_DIR, 'private-list.json');
29
32
  const TTL_MS = 5 * 60 * 1000;
30
33
 
31
34
  const projectFileCache = new Map(); // cwd → { ts, value | null }
@@ -82,7 +85,14 @@ function readPrivateList() {
82
85
  if (!existsSync(PRIVATE_LIST_PATH)) return { paths: [] };
83
86
  try {
84
87
  const v = JSON.parse(readFileSync(PRIVATE_LIST_PATH, 'utf8'));
85
- if (Array.isArray(v?.paths)) return v;
88
+ if (v && typeof v === 'object') {
89
+ // `paths` is the legacy binary list (presence ≡ hidden). `visibility`
90
+ // is the richer GUI-managed map: { "<abs cwd>": "hidden|name-only|public" }.
91
+ return {
92
+ paths: Array.isArray(v.paths) ? v.paths : [],
93
+ visibility: (v.visibility && typeof v.visibility === 'object') ? v.visibility : undefined,
94
+ };
95
+ }
86
96
  } catch { /* broken JSON ≡ no list (treat as empty rather than crash) */ }
87
97
  return { paths: [] };
88
98
  }
@@ -122,6 +132,50 @@ function isInPrivateList(cwd) {
122
132
  );
123
133
  }
124
134
 
135
+ // ── Central visibility map (GUI-managed) ────────────────────────────────
136
+ // A richer alternative to the binary `paths` list: maps an absolute cwd to
137
+ // an explicit visibility level. Highest-priority runtime layer — an explicit
138
+ // 'public' here even overrides config-glob / gh auto-detect, because the user
139
+ // deliberately marked it. Subdirectories inherit a marked parent.
140
+
141
+ const VIS_LEVELS = ['public', 'name-only', 'hidden'];
142
+ function normLevel(v) { return VIS_LEVELS.includes(v) ? v : null; }
143
+
144
+ function visibilityForCwd(cwd) {
145
+ if (!cwd) return null;
146
+ const map = readPrivateList().visibility;
147
+ if (!map) return null;
148
+ const abs = resolvePath(cwd);
149
+ if (map[abs]) return normLevel(map[abs]);
150
+ for (const [p, v] of Object.entries(map)) {
151
+ if (abs === p || abs.startsWith(p + '/') || abs.startsWith(p + '\\')) return normLevel(v);
152
+ }
153
+ return null;
154
+ }
155
+
156
+ // Set (or clear) the explicit visibility for a cwd. `level` of null/'default'
157
+ // removes the override so resolution falls back through the lower layers.
158
+ // Returns the updated visibility map.
159
+ export function setCwdVisibility(cwd, level) {
160
+ const abs = resolvePath(cwd);
161
+ const list = readPrivateList();
162
+ const map = (list.visibility && typeof list.visibility === 'object') ? list.visibility : {};
163
+ const norm = normLevel(level);
164
+ if (norm) map[abs] = norm;
165
+ else delete map[abs];
166
+ // A path managed by the map shouldn't also linger in the legacy binary
167
+ // list, where it would always read as hidden regardless of the map value.
168
+ list.paths = (list.paths || []).filter((p) => p !== abs);
169
+ list.visibility = map;
170
+ writePrivateList(list);
171
+ return map;
172
+ }
173
+
174
+ // Read the current explicit-visibility map (abs cwd → level).
175
+ export function listVisibility() {
176
+ return readPrivateList().visibility || {};
177
+ }
178
+
125
179
  // ── GitHub-private detection (best-effort, gh CLI) ──────────────────────
126
180
 
127
181
  function detectGithubPrivate(cwd) {
@@ -151,6 +205,12 @@ export function resolveVisibility(cwd, config = {}) {
151
205
  if (proj?.visibility) {
152
206
  return { visibility: proj.visibility, projectName: proj.projectName, reason: '.claude-rpc.json' };
153
207
  }
208
+ // Central GUI-managed map (incl. explicit 'public' as an opt-out of
209
+ // auto-hide). Ranks above the legacy binary list and config/gh layers.
210
+ const mapped = visibilityForCwd(cwd);
211
+ if (mapped) {
212
+ return { visibility: mapped, projectName: proj?.projectName ?? null, reason: 'private-list (visibility)' };
213
+ }
154
214
  if (isInPrivateList(cwd)) {
155
215
  return { visibility: 'hidden', projectName: proj?.projectName ?? null, reason: 'private-list' };
156
216
  }
package/src/server/api.js CHANGED
@@ -173,3 +173,38 @@ export function dayDetail(dayKeyStr) {
173
173
  if (!day) return null;
174
174
  return { day: dayKeyStr, ...day };
175
175
  }
176
+
177
+ // Flatten the aggregate's byDay map into a daily-rows CSV for spreadsheet /
178
+ // pandas analysis. One row per day, sorted ascending. Date keys are
179
+ // YYYY-MM-DD and all other columns are numeric, so nothing needs quoting.
180
+ export const CSV_COLUMNS = [
181
+ 'date', 'activeMs', 'activeHours', 'sessions', 'userMessages', 'toolCalls',
182
+ 'linesAdded', 'linesRemoved', 'cost', 'inputTokens', 'outputTokens',
183
+ 'cacheReadTokens', 'cacheWriteTokens', 'notifications',
184
+ ];
185
+
186
+ export function aggregateToCsv(agg) {
187
+ const byDay = (agg && agg.byDay) || {};
188
+ const rows = [CSV_COLUMNS.join(',')];
189
+ for (const date of Object.keys(byDay).sort()) {
190
+ const d = byDay[date] || {};
191
+ const activeMs = d.activeMs || 0;
192
+ rows.push([
193
+ date,
194
+ activeMs,
195
+ (activeMs / 3_600_000).toFixed(3),
196
+ d.sessions || 0,
197
+ d.userMessages || 0,
198
+ d.toolCalls || 0,
199
+ d.linesAdded || 0,
200
+ d.linesRemoved || 0,
201
+ (d.cost || 0).toFixed(4),
202
+ d.inputTokens || 0,
203
+ d.outputTokens || 0,
204
+ d.cacheReadTokens || 0,
205
+ d.cacheWriteTokens || 0,
206
+ d.notifications || 0,
207
+ ].join(','));
208
+ }
209
+ return rows.join('\n') + '\n';
210
+ }