claude-code-runrate 0.4.0 → 0.5.0

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.
@@ -0,0 +1,435 @@
1
+ // @ts-check
2
+ 'use strict';
3
+ // src/history-privacy.js — what a release would publish that is not published
4
+ // yet, read out of the object store rather than out of the working tree.
5
+ //
6
+ // Why this is not simply `git grep` over the working tree: a scrubbed tip says
7
+ // nothing about the blobs behind it. 0.4.0 made the shape concrete — its tree
8
+ // grepped clean while fourteen of the twenty-three commits behind that tip
9
+ // still carried a string the release had deliberately removed. Invisible to
10
+ // every check anyone ran, because every check ran on the tree.
11
+ //
12
+ // That particular string turned out to be publishable after all (ruled
13
+ // 2026-08-07; the flat squash it prompted cost nothing either way). The
14
+ // arithmetic is the lesson rather than the string: fourteen commits, none of
15
+ // them reachable from an inspection of the tip, and nothing in the release path
16
+ // that would have looked.
17
+ //
18
+ // The disclosures this repository has ACTUALLY had were both mail addresses —
19
+ // an owner address throughout early history, and a contributor's real address
20
+ // on a branch that reached the public remote in 2026-06. Neither was in the tip
21
+ // when it mattered. Both are what the real-inbox detector below is for.
22
+ //
23
+ // So the check reads history, not `HEAD`: every commit reachable from the tip
24
+ // and not from the published ref, every blob in each of their trees.
25
+ //
26
+ // TWO KINDS OF FINDING, deliberately different:
27
+ //
28
+ // * DETECTORS (below) are generic and live here in the open, because they
29
+ // describe SHAPES — an email that is not a noreply alias, an absolute home
30
+ // path — not anyone's actual secrets. They cover both disclosures above.
31
+ //
32
+ // * The PRIVATE SUPPLEMENT is a list of literal patterns supplied from
33
+ // OUTSIDE the repository (see loadPrivatePatterns). Whatever belongs on it
34
+ // is by definition a string this file must not contain: writing it into a
35
+ // public file in order to scan for it would be the disclosure it prevents.
36
+ //
37
+ // Detector hits are judged against a BASELINE: whatever the published tree
38
+ // already contains is, by definition, already public and not news. That is why
39
+ // there is no allowlist to maintain — the npm contact alias in package.json is
40
+ // silent because it is already out there, and it would start speaking again
41
+ // the moment it appeared somewhere it had not been. Supplement hits ignore the
42
+ // baseline: those strings are never acceptable, published already or not.
43
+
44
+ const fs = require('node:fs');
45
+ const os = require('node:os');
46
+ const path = require('node:path');
47
+
48
+ const { readObject, parseTree } = require('./git-objects');
49
+
50
+ /** Commits walked before the scan gives up rather than grind. */
51
+ const MAX_COMMITS = 2000;
52
+ /** Distinct blobs read per scan. Trees and blobs are deduplicated by oid, so
53
+ * this counts real content, not path instances. */
54
+ const MAX_BLOBS = 20000;
55
+ /** A blob larger than this is not read. Secrets hide in text, and the object
56
+ * reader has its own ceiling anyway. */
57
+ const MAX_BLOB_BYTES = 1024 * 1024;
58
+ /** Bytes inspected for NUL before calling a blob binary and skipping it. */
59
+ const BINARY_SNIFF_BYTES = 8192;
60
+
61
+ /**
62
+ * A generic shape worth refusing to publish. `extract` returns the literal
63
+ * strings found, which is what makes baseline comparison possible: the finding
64
+ * is the STRING, so "already public" is a set membership test.
65
+ *
66
+ * @typedef {object} Detector
67
+ * @property {string} name
68
+ * @property {string} why Printed to the operator, so it must explain itself.
69
+ * @property {(text: string) => string[]} extract
70
+ */
71
+
72
+ /**
73
+ * Addresses that are aliases by construction. This is not an allowlist of
74
+ * anyone's real mail — it is the set of names that CANNOT reach an inbox,
75
+ * which is why a fixture is entitled to use them freely.
76
+ *
77
+ * The reserved names are RFC 2606 and RFC 6761: the `.invalid`, `.test`,
78
+ * `.example` and `.localhost` TLDs, and the `example.com/org/net` domains.
79
+ * They are matched as SUFFIXES, so `oracle@ccr.invalid` and
80
+ * `someone@corp.example.com` are covered — an earlier version anchored on
81
+ * `@invalid$` and flagged this repository's own test fixtures.
82
+ */
83
+ const ALIAS_MAIL = /(@users\.noreply\.github\.com|@noreply\.[A-Za-z0-9.-]+|^noreply@|\.(invalid|test|example|localhost)$|(^|@|\.)example\.(com|org|net)$)/i;
84
+
85
+ const MAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
86
+
87
+ /**
88
+ * `/home/<user>/`, `/Users/<user>/`, `C:\Users\<user>\` — an absolute path
89
+ * through somebody's home directory. Generic placeholders are not findings.
90
+ */
91
+ const HOME_RE = /(?:\/home\/|\/Users\/|[A-Za-z]:\\Users\\)([A-Za-z0-9._-]+)/g;
92
+ const PLACEHOLDER_USER = /^(user|username|you|me|someone|test|runner|ci|root|foo|bar|example|\$\{?\w+\}?)$/i;
93
+
94
+ /** @type {Detector[]} */
95
+ const DETECTORS = [
96
+ {
97
+ name: 'real-inbox',
98
+ why: 'an email address that is not a noreply alias',
99
+ extract: (text) => (text.match(MAIL_RE) || []).filter((m) => !ALIAS_MAIL.test(m)),
100
+ },
101
+ {
102
+ name: 'home-path',
103
+ why: 'an absolute path naming a home directory',
104
+ extract: (text) => {
105
+ /** @type {string[]} */
106
+ const out = [];
107
+ for (const m of text.matchAll(HOME_RE)) {
108
+ if (!PLACEHOLDER_USER.test(m[1])) out.push(m[0]);
109
+ }
110
+ return out;
111
+ },
112
+ },
113
+ ];
114
+
115
+ /**
116
+ * The private supplement: literal ERE-ish patterns that must never appear,
117
+ * supplied from outside the repository so the repository never states them.
118
+ *
119
+ * Sources, first one that exists wins:
120
+ * 1. $CCR_PRIVATE_PATTERNS — patterns inline, comma or newline separated
121
+ * 2. $CCR_PRIVATE_PATTERNS_FILE — a file of them, one per line, `#` comments
122
+ * 3. ~/.config/ccr/private-patterns
123
+ *
124
+ * Absent all three the supplement is empty and `configured` is false, which
125
+ * the gate reports rather than swallows: a check that quietly did not run is
126
+ * worse than one that says so.
127
+ *
128
+ * @param {{ env?: NodeJS.ProcessEnv, homedir?: () => string }} [deps]
129
+ * @returns {{ configured: boolean, source: string|null, patterns: RegExp[], invalid: string[] }}
130
+ */
131
+ function loadPrivatePatterns(deps = {}) {
132
+ const env = deps.env || process.env;
133
+ const home = (deps.homedir || os.homedir)();
134
+
135
+ /** @type {string|null} */
136
+ let raw = null;
137
+ /** @type {string|null} */
138
+ let source = null;
139
+
140
+ if (env.CCR_PRIVATE_PATTERNS && env.CCR_PRIVATE_PATTERNS.trim()) {
141
+ raw = env.CCR_PRIVATE_PATTERNS;
142
+ source = '$CCR_PRIVATE_PATTERNS';
143
+ } else {
144
+ const file = env.CCR_PRIVATE_PATTERNS_FILE
145
+ || path.join(home, '.config', 'ccr', 'private-patterns');
146
+ try {
147
+ raw = fs.readFileSync(file, 'utf8');
148
+ source = file;
149
+ } catch {
150
+ raw = null;
151
+ }
152
+ }
153
+
154
+ if (raw === null) return { configured: false, source: null, patterns: [], invalid: [] };
155
+
156
+ /** @type {RegExp[]} */
157
+ const patterns = [];
158
+ /** @type {string[]} */
159
+ const invalid = [];
160
+ for (const line of raw.split(/[\n,]/)) {
161
+ const s = line.trim();
162
+ if (!s || s.startsWith('#')) continue;
163
+ try {
164
+ patterns.push(new RegExp(s, 'i'));
165
+ } catch {
166
+ invalid.push(s);
167
+ }
168
+ }
169
+ return { configured: true, source, patterns, invalid };
170
+ }
171
+
172
+ /**
173
+ * Commits reachable from `tip` and not from `published`.
174
+ *
175
+ * The exclusion side is walked first and in full, so a commit that is an
176
+ * ancestor of the published ref is never reported no matter which order the
177
+ * tip walk reaches it in.
178
+ *
179
+ * A commit whose object cannot be read is REPORTED, not skipped. Dropping it
180
+ * would shrink the answer silently: an unreadable tip would walk to an empty
181
+ * list, and an empty list of unpublished commits is indistinguishable from
182
+ * having nothing to disclose. The caller must be able to tell those apart.
183
+ *
184
+ * @param {string} gitDir
185
+ * @param {string} tip
186
+ * @param {string|null} published Null means "nothing is published yet".
187
+ * @returns {{ commits: string[], unreadable: string[], truncated: boolean }}
188
+ */
189
+ function unpublishedCommits(gitDir, tip, published) {
190
+ const parentsOf = (/** @type {string} */ oid) => {
191
+ const obj = readObject(gitDir, oid);
192
+ if (obj === null || obj.type !== 'commit') return null;
193
+ const header = obj.data.toString('utf8', 0, Math.min(obj.data.length, 8192));
194
+ const end = header.indexOf('\n\n');
195
+ const head = end === -1 ? header : header.slice(0, end);
196
+ return [...head.matchAll(/^parent ([0-9a-f]{40}|[0-9a-f]{64})$/gm)].map((m) => m[1]);
197
+ };
198
+
199
+ /** @type {Set<string>} */
200
+ const excluded = new Set();
201
+ if (published !== null) {
202
+ const stack = [published];
203
+ while (stack.length > 0 && excluded.size < MAX_COMMITS) {
204
+ const oid = /** @type {string} */ (stack.pop());
205
+ if (excluded.has(oid)) continue;
206
+ excluded.add(oid);
207
+ const ps = parentsOf(oid);
208
+ if (ps !== null) stack.push(...ps);
209
+ }
210
+ }
211
+
212
+ /** @type {string[]} */
213
+ const commits = [];
214
+ /** @type {string[]} */
215
+ const unreadable = [];
216
+ /** @type {Set<string>} */
217
+ const seen = new Set();
218
+ const stack = [tip];
219
+ let truncated = false;
220
+ while (stack.length > 0) {
221
+ const oid = /** @type {string} */ (stack.pop());
222
+ if (seen.has(oid) || excluded.has(oid)) continue;
223
+ seen.add(oid);
224
+ if (commits.length >= MAX_COMMITS) { truncated = true; break; }
225
+ const ps = parentsOf(oid);
226
+ if (ps === null) { unreadable.push(oid); continue; }
227
+ commits.push(oid);
228
+ stack.push(...ps);
229
+ }
230
+ return { commits, unreadable, truncated };
231
+ }
232
+
233
+ /**
234
+ * Every blob in a commit's tree, as `path -> oid`, following subtrees and
235
+ * skipping gitlinks (mode 0o160000 — a submodule pointer names a commit in
236
+ * another repository, whose contents are not ours to read).
237
+ *
238
+ * @param {string} gitDir
239
+ * @param {string} treeOid
240
+ * @param {Set<string>} treesSeen Shared across commits: sibling releases share
241
+ * almost all of their trees, and re-walking them is the whole cost.
242
+ * @param {(path: string, oid: string) => void} onBlob
243
+ */
244
+ function walkTree(gitDir, treeOid, treesSeen, onBlob, prefix = '') {
245
+ if (treesSeen.has(treeOid)) return;
246
+ treesSeen.add(treeOid);
247
+ const obj = readObject(gitDir, treeOid);
248
+ if (obj === null || obj.type !== 'tree') return;
249
+ const entries = parseTree(obj.data, treeOid.length / 2);
250
+ if (entries === null) return;
251
+ for (const e of entries) {
252
+ const full = prefix ? `${prefix}/${e.name}` : e.name;
253
+ if (e.mode === 0o160000) continue;
254
+ if (e.mode === 0o40000) walkTree(gitDir, e.oid, treesSeen, onBlob, full);
255
+ else onBlob(full, e.oid);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Read a blob as text, or null when it is missing, oversized or binary.
261
+ * @param {string} gitDir
262
+ * @param {string} oid
263
+ */
264
+ function readTextBlob(gitDir, oid) {
265
+ const obj = readObject(gitDir, oid);
266
+ if (obj === null || obj.type !== 'blob') return null;
267
+ if (obj.data.length > MAX_BLOB_BYTES) return null;
268
+ if (obj.data.indexOf(0, 0) !== -1
269
+ && obj.data.indexOf(0, 0) < BINARY_SNIFF_BYTES) return null;
270
+ return obj.data.toString('utf8');
271
+ }
272
+
273
+ /**
274
+ * Every literal a detector finds anywhere in a tree — the baseline of what is
275
+ * already public, so the same string appearing again is not a new disclosure.
276
+ *
277
+ * @param {string} gitDir
278
+ * @param {string} treeOid
279
+ * @returns {Set<string>}
280
+ */
281
+ function baselineLiterals(gitDir, treeOid) {
282
+ /** @type {Set<string>} */
283
+ const found = new Set();
284
+ /** @type {Set<string>} */
285
+ const trees = new Set();
286
+ /** @type {Set<string>} */
287
+ const blobs = new Set();
288
+ walkTree(gitDir, treeOid, trees, (_p, oid) => {
289
+ if (blobs.has(oid) || blobs.size >= MAX_BLOBS) return;
290
+ blobs.add(oid);
291
+ const text = readTextBlob(gitDir, oid);
292
+ if (text === null) return;
293
+ for (const d of DETECTORS) for (const lit of d.extract(text)) found.add(lit);
294
+ });
295
+ return found;
296
+ }
297
+
298
+ /**
299
+ * @typedef {object} PrivacyHit
300
+ * @property {string} commit Oid of the commit whose tree carries it.
301
+ * @property {string} path Path within that tree.
302
+ * @property {string} kind Detector name, or 'private-pattern'.
303
+ * @property {string} why Human sentence for the operator.
304
+ * @property {string} [literal] The offending string, when it is safe to print
305
+ * (detector hits only — a supplement hit prints its pattern, never its match).
306
+ */
307
+
308
+ /**
309
+ * Scan the unpublished history.
310
+ *
311
+ * @param {string} gitDir
312
+ * @param {object} opts
313
+ * @param {string} opts.tip Commit about to be published from.
314
+ * @param {string|null} opts.published Commit currently public, or null.
315
+ * @param {RegExp[]} [opts.privatePatterns]
316
+ * @param {string[]} [opts.allow] Literals known to be invented, joined to
317
+ * the baseline. Detector hits only — a private pattern is never waived here.
318
+ * @returns {{ state: 'clean'|'hits'|'unavailable', hits: PrivacyHit[],
319
+ * commitsScanned: number, blobsScanned: number, truncated: boolean }}
320
+ */
321
+ function scanHistory(gitDir, opts) {
322
+ const priv = opts.privatePatterns || [];
323
+ const walk = unpublishedCommits(gitDir, opts.tip, opts.published);
324
+ const { commits, truncated } = walk;
325
+
326
+ // ANY commit the object store could not answer for makes the whole scan
327
+ // inconclusive, and inconclusive is not clean. A gate that cleared a release
328
+ // because it failed to read the evidence would be worse than no gate: it
329
+ // would report success in exactly the situation it exists to catch.
330
+ if (walk.unreadable.length > 0) {
331
+ return {
332
+ state: 'unavailable', hits: [], commitsScanned: 0, blobsScanned: 0, truncated,
333
+ };
334
+ }
335
+
336
+ if (commits.length === 0) {
337
+ return { state: 'clean', hits: [], commitsScanned: 0, blobsScanned: 0, truncated };
338
+ }
339
+
340
+ // What the published tree already says. Absent a published ref there is no
341
+ // baseline and every detector hit is news, which is the correct reading of
342
+ // a first publication.
343
+ /** @type {Set<string>} */
344
+ let baseline = new Set(opts.allow || []);
345
+ if (opts.published !== null) {
346
+ const obj = readObject(gitDir, opts.published);
347
+ if (obj !== null && obj.type === 'commit') {
348
+ const m = /^tree ([0-9a-f]{40}|[0-9a-f]{64})$/m.exec(
349
+ obj.data.toString('latin1', 0, Math.min(obj.data.length, 256)));
350
+ // Merged, not assigned: the allow list must survive the published tree's
351
+ // own literals being read in on top of it.
352
+ if (m) for (const lit of baselineLiterals(gitDir, m[1])) baseline.add(lit);
353
+ }
354
+ }
355
+
356
+ /** @type {PrivacyHit[]} */
357
+ const hits = [];
358
+ let unreadable = 0;
359
+ let blobCeiling = false;
360
+
361
+ // Reading and matching a blob is the expensive half and is done ONCE per
362
+ // oid. Attribution is the cheap half and is done per commit: the same blob
363
+ // usually survives many commits, and an operator who is told only about the
364
+ // first one would accept that commit and publish the other nine. The earlier
365
+ // shape of this function made exactly that mistake.
366
+ /** @type {Map<string, Array<{ kind: string, why: string, literal?: string }>>} */
367
+ const verdicts = new Map();
368
+ const verdictFor = (/** @type {string} */ oid) => {
369
+ const cached = verdicts.get(oid);
370
+ if (cached !== undefined) return cached;
371
+ if (verdicts.size >= MAX_BLOBS) { blobCeiling = true; return []; }
372
+ /** @type {Array<{ kind: string, why: string, literal?: string }>} */
373
+ const found = [];
374
+ const text = readTextBlob(gitDir, oid);
375
+ if (text !== null) {
376
+ // One finding per distinct literal per blob. A string repeated forty
377
+ // times in a file is one disclosure, and forty lines of it would bury
378
+ // the other findings the operator needs to see.
379
+ /** @type {Set<string>} */
380
+ const already = new Set();
381
+ for (const d of DETECTORS) {
382
+ for (const lit of d.extract(text)) {
383
+ if (baseline.has(lit) || already.has(lit)) continue;
384
+ already.add(lit);
385
+ found.push({ kind: d.name, why: d.why, literal: lit });
386
+ }
387
+ }
388
+ for (const re of priv) {
389
+ if (re.test(text)) {
390
+ found.push({ kind: 'private-pattern', why: `matches the private pattern /${re.source}/` });
391
+ }
392
+ }
393
+ }
394
+ verdicts.set(oid, found);
395
+ return found;
396
+ };
397
+
398
+ for (const commit of commits) {
399
+ const obj = readObject(gitDir, commit);
400
+ if (obj === null || obj.type !== 'commit') { unreadable += 1; continue; }
401
+ const m = /^tree ([0-9a-f]{40}|[0-9a-f]{64})$/m.exec(
402
+ obj.data.toString('latin1', 0, Math.min(obj.data.length, 256)));
403
+ if (!m) { unreadable += 1; continue; }
404
+
405
+ // Per-commit, so a tree shared with an already-walked commit is still
406
+ // attributed to this one. Within a commit it still collapses duplicates.
407
+ /** @type {Set<string>} */
408
+ const treesSeen = new Set();
409
+ walkTree(gitDir, m[1], treesSeen, (p, oid) => {
410
+ for (const v of verdictFor(oid)) hits.push({ commit, path: p, ...v });
411
+ });
412
+ }
413
+
414
+ // Same rule one level down: a commit that resolved but whose tree header did
415
+ // not parse leaves part of the history unexamined.
416
+ if (unreadable > 0) {
417
+ return {
418
+ state: 'unavailable', hits: [], commitsScanned: 0,
419
+ blobsScanned: verdicts.size, truncated: truncated || blobCeiling,
420
+ };
421
+ }
422
+
423
+ return {
424
+ state: hits.length > 0 ? 'hits' : 'clean',
425
+ hits,
426
+ commitsScanned: commits.length,
427
+ blobsScanned: verdicts.size,
428
+ truncated: truncated || blobCeiling,
429
+ };
430
+ }
431
+
432
+ module.exports = {
433
+ scanHistory, unpublishedCommits, loadPrivatePatterns, walkTree, baselineLiterals,
434
+ DETECTORS, MAX_COMMITS, MAX_BLOBS, MAX_BLOB_BYTES,
435
+ };