claude-code-runrate 0.5.0 → 0.6.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.
- package/CHANGELOG.md +40 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/scripts/launch.sh +8 -1
- package/sidecar/ccr.tmux.conf +38 -0
- package/src/doctor.js +40 -4
- package/src/history-privacy.js +0 -435
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@ version is worth upgrading to, and where to read the rest.
|
|
|
7
7
|
|
|
8
8
|
Dates are npm publish dates, because that is when a version reached anyone.
|
|
9
9
|
|
|
10
|
+
## 0.6.0 — 2026-09-01
|
|
11
|
+
|
|
12
|
+
The remote-session release. Over mosh, two things had been degrading in
|
|
13
|
+
silence — every clipboard copy was dropped in transit, and 24-bit colour was
|
|
14
|
+
quietly flattened to 256 — and the tmux version floor the sidecar had grown
|
|
15
|
+
was written down nowhere. All three were measured at the terminal, reading
|
|
16
|
+
the raw bytes that reach a mosh client, not inferred.
|
|
17
|
+
|
|
18
|
+
- **The clipboard survives mosh.** tmux's built-in OSC 52 emits an empty
|
|
19
|
+
selection field and mosh's parser accepts only an explicit `c`, so every
|
|
20
|
+
copy vanished between the two. The shipped tmux config now spells the
|
|
21
|
+
selection out — and names `c` explicitly, which is *more* compatible than
|
|
22
|
+
tmux's default everywhere, not a mosh-only workaround.
|
|
23
|
+
- **True colour survives mosh.** mosh forces `TERM=xterm-256color` and drops
|
|
24
|
+
`COLORTERM`, which tmux reads as "no RGB" and silently downgrades every
|
|
25
|
+
24-bit colour to the nearest 256. The config now declares RGB for that
|
|
26
|
+
TERM, restoring what mosh was carrying fine all along.
|
|
27
|
+
- **The tmux floor is declared: 3.2.** Percentage splits and
|
|
28
|
+
`terminal-features` need 3.1; the pane-scoped hooks that keep the sidebar
|
|
29
|
+
out of copy-mode need 3.2. Below the floor each failure is silent and looks
|
|
30
|
+
like something else, so `ccr doctor` now reports the tmux version and names
|
|
31
|
+
one that is too old — and an unreadable version is reported as unreadable,
|
|
32
|
+
never manufactured into a failure.
|
|
33
|
+
- **The sidebar split speaks tmux 3.4's dialect.** `-p 34` was deprecated in
|
|
34
|
+
3.1 and rejected outright in 3.4; the launcher sizes with `-l 34%`, which
|
|
35
|
+
every supported tmux accepts.
|
|
36
|
+
- **The Node floor rises to 22.17.** Node 18 and 20 are past their end of
|
|
37
|
+
life, and the vendored test runner's own floor is 22.17 — testing below it
|
|
38
|
+
produced a lane that hung rather than failed. `engines`, the CI matrix,
|
|
39
|
+
`ccr doctor`'s check, and the spec all state the same floor now, and the
|
|
40
|
+
matrix pins 22.17 exactly so the floor is tested, not just declared.
|
|
41
|
+
- **The acceptance suite hardened underneath.** The vendored Gherkin runner
|
|
42
|
+
moved to gherkin-node-test 0.11.0, whose step linter enforces a rule the
|
|
43
|
+
suite now lives by: a negative assertion over a literal needle passes
|
|
44
|
+
forever once the needle rots, so *absence must be earned* — by a control
|
|
45
|
+
proving the needle can still find, or a sanction naming what proves it.
|
|
46
|
+
Every structural refusal in the step layer (the renderer spawns nothing,
|
|
47
|
+
the launcher reads no blob, the sidecar has no input channel) now carries
|
|
48
|
+
one or the other, and both of 0.11's linters gate the suite.
|
|
49
|
+
|
|
10
50
|
## 0.5.0 — 2026-08-22
|
|
11
51
|
|
|
12
52
|
The Windows release. The things that could not be reasoned about were measured
|
package/README.md
CHANGED
|
@@ -69,8 +69,8 @@ On Linux/macOS the default host is `tmux`. No WSL required.
|
|
|
69
69
|
|
|
70
70
|
## Requirements
|
|
71
71
|
|
|
72
|
-
- **Node ≥
|
|
73
|
-
- For the optional live `ccr sidecar`:
|
|
72
|
+
- **Node ≥ 22.17** — that's it for the core. **Zero runtime dependencies.**
|
|
73
|
+
- For the optional live `ccr sidecar`: **`tmux` ≥ 3.2** on Linux/macOS, or **Windows
|
|
74
74
|
Terminal** / **VS Code's integrated terminal** on Windows (no WSL).
|
|
75
75
|
|
|
76
76
|
## VS Code (split terminal)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-runrate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Claude Code run-rate — subscription burn-rate & economy for your Claude Code sessions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bing Ho <reps-attic-riot@duck.com>",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"ccr": "bin/ccr.js"
|
|
18
18
|
},
|
|
19
19
|
"engines": {
|
|
20
|
-
"node": ">=
|
|
20
|
+
"node": ">=22.17"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"bin",
|
package/scripts/launch.sh
CHANGED
|
@@ -135,7 +135,14 @@ if [ -n "${CCR_TITLE:-}" ]; then
|
|
|
135
135
|
fi
|
|
136
136
|
|
|
137
137
|
# Pane 1: the live economy sidebar. Capture its pane id so we can scope a hook to it.
|
|
138
|
-
|
|
138
|
+
# The width is `-l <pct>%`, NOT `-p <pct>`. Both spell the same split, but `-p` is the
|
|
139
|
+
# deprecated form and tmux 3.4 — what Ubuntu 24.04 LTS ships, i.e. every stock Ubuntu
|
|
140
|
+
# Server — rejects it with `size missing`. That killed the launcher AFTER new-session
|
|
141
|
+
# had already succeeded, so the failure mode was silent and confusing: a live Claude
|
|
142
|
+
# pane, no sidebar, no error on screen (launch.sh's stderr goes to the terminal it was
|
|
143
|
+
# spawned from, which is gone by then). `-l N%` is accepted by 3.1 through 3.6+ alike
|
|
144
|
+
# and produces an identical split, so there is no version guard to maintain.
|
|
145
|
+
SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -l "${CCR_SIDEBAR_PCT:-34}%" -P -F '#{pane_id}' \
|
|
139
146
|
"$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '")"
|
|
140
147
|
|
|
141
148
|
# The sidebar is a live dashboard — there is nothing to scroll. A stray mouse-wheel
|
package/sidecar/ccr.tmux.conf
CHANGED
|
@@ -22,3 +22,41 @@ set -g set-clipboard on
|
|
|
22
22
|
|
|
23
23
|
# prefix + P — dump the whole pane scrollback to a file, regardless of length.
|
|
24
24
|
bind-key P command-prompt -p 'save pane to:' 'capture-pane -S - ; save-buffer %1'
|
|
25
|
+
|
|
26
|
+
# --- Remote sessions: true colour and a clipboard that survives mosh ---------
|
|
27
|
+
#
|
|
28
|
+
# Both lines matter only over a remote transport, and mosh is the case that
|
|
29
|
+
# breaks without them. Measured over mosh -> tmux, reading the raw bytes that
|
|
30
|
+
# reach the client; neither is guesswork.
|
|
31
|
+
#
|
|
32
|
+
# 1. TRUE COLOUR. mosh forces TERM=xterm-256color and DROPS COLORTERM. tmux
|
|
33
|
+
# infers RGB support from COLORTERM, so with it gone tmux silently
|
|
34
|
+
# downgrades 24-bit colour to the nearest 256 (38;2;R;G;B -> 38;5;N). mosh
|
|
35
|
+
# itself carries 24-bit fine; tmux is the one degrading it. Declaring the
|
|
36
|
+
# feature restores it.
|
|
37
|
+
#
|
|
38
|
+
# 2. CLIPBOARD. tmux's built-in OSC 52 emits an EMPTY selection field
|
|
39
|
+
# (`\033]52;;<base64>`), and mosh's OSC 52 parser accepts ONLY an explicit
|
|
40
|
+
# `c`. So every copy is silently dropped in transit. This override spells
|
|
41
|
+
# the selection out. Three details are load-bearing:
|
|
42
|
+
# * `%p1%.0s` CONSUMES the selection parameter while printing nothing.
|
|
43
|
+
# Omitting it leaves p1 unconsumed and the expansion is invalid.
|
|
44
|
+
# * `\007`, never `\7`. tmux 3.x SILENTLY REJECTS a terminal-overrides
|
|
45
|
+
# value containing `\7` — the option is discarded whole with no error,
|
|
46
|
+
# and `tmux show -s terminal-overrides` simply will not list it. Older
|
|
47
|
+
# guides on the web all use `\7`; that spelling was fine on tmux 2.x.
|
|
48
|
+
# * terminal-overrides keys on the client's TERM, so this matches when
|
|
49
|
+
# TERM is xterm-256color — which is exactly what mosh sets.
|
|
50
|
+
# Emitting an explicit `c` is also strictly more compatible than tmux's
|
|
51
|
+
# default empty field, so this is not a mosh-only workaround.
|
|
52
|
+
#
|
|
53
|
+
# Both require tmux 3.1+; ccr asks for 3.2+ overall (see `ccr doctor`).
|
|
54
|
+
#
|
|
55
|
+
# RGB is declared unconditionally. 24-bit has been table stakes for years —
|
|
56
|
+
# iTerm2, Ghostty, WezTerm, kitty, Alacritty, Konsole, every libvte terminal
|
|
57
|
+
# (GNOME Terminal et al), Windows Terminal and PuTTY all handle it. The lone
|
|
58
|
+
# holdout that reports TERM=xterm-256color without it is macOS Terminal.app
|
|
59
|
+
# before macOS 26, which degrades rather than breaking. Optimising the default
|
|
60
|
+
# for that shrinking case would cost every other user their colours.
|
|
61
|
+
set -as terminal-features ',xterm-256color:RGB'
|
|
62
|
+
set -as terminal-overrides ",xterm-256color:Ms=\E]52;c%p1%.0s;%p2%s\007"
|
package/src/doctor.js
CHANGED
|
@@ -35,8 +35,27 @@ function isExec(/** @type {string} */ f) {
|
|
|
35
35
|
try { return (fs.statSync(f).mode & 0o111) !== 0; } catch { return false; }
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// ccr's tmux dialect has a floor. 3.1 is HARD: percentage splits
|
|
39
|
+
// (`split-window -l 34%`, since `-p` was deprecated there and 3.4+ rejects it)
|
|
40
|
+
// and `terminal-features`, which sidecar/ccr.tmux.conf needs for true colour
|
|
41
|
+
// over mosh. 3.2 adds pane-scoped hooks, which the launcher uses to keep the
|
|
42
|
+
// sidebar out of copy-mode; below that it degrades quietly rather than
|
|
43
|
+
// breaking, so 3.2 is what we ask for and 3.1 is what we tolerate.
|
|
44
|
+
const TMUX_MIN = { major: 3, minor: 2 };
|
|
45
|
+
/** @param {string} bin @returns {{major:number,minor:number}|null} */
|
|
46
|
+
function tmuxVersion(bin) {
|
|
47
|
+
try {
|
|
48
|
+
const r = spawnSync(bin, ['-V'], { encoding: 'utf8' });
|
|
49
|
+
if (r.status !== 0) return null;
|
|
50
|
+
// "tmux 3.7c", "tmux 3.2a", "tmux next-3.6" — only the numbers matter.
|
|
51
|
+
const m = /(\d+)\.(\d+)/.exec(r.stdout || '');
|
|
52
|
+
return m ? { major: Number(m[1]), minor: Number(m[2]) } : null;
|
|
53
|
+
} catch { return null; }
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
/**
|
|
39
57
|
* @param {{ platform?: string, has?: (cmd: string) => (string|null),
|
|
58
|
+
* tmuxVersion?: (bin: string) => ({major:number,minor:number}|null),
|
|
40
59
|
* homedir?: string, repo?: string, write?: (s: string) => void,
|
|
41
60
|
* env?: Record<string, string|undefined> }} [opts]
|
|
42
61
|
* side effects are injectable for testing; defaults hit the real environment
|
|
@@ -45,6 +64,7 @@ function isExec(/** @type {string} */ f) {
|
|
|
45
64
|
function run(opts = {}) {
|
|
46
65
|
const platform = opts.platform || process.platform;
|
|
47
66
|
const hasFn = opts.has || has;
|
|
67
|
+
const tmuxVerFn = opts.tmuxVersion || tmuxVersion;
|
|
48
68
|
const homedir = opts.homedir || os.homedir();
|
|
49
69
|
const REPO = opts.repo || path.join(__dirname, '..');
|
|
50
70
|
const write = opts.write || ((s) => { process.stdout.write(s); });
|
|
@@ -53,8 +73,8 @@ function run(opts = {}) {
|
|
|
53
73
|
let problems = 0;
|
|
54
74
|
|
|
55
75
|
const [maj, min] = process.versions.node.split('.').map(Number);
|
|
56
|
-
const nodeOk = maj >
|
|
57
|
-
out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >=
|
|
76
|
+
const nodeOk = maj > 22 || (maj === 22 && min >= 17);
|
|
77
|
+
out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >= 22.17`));
|
|
58
78
|
if (!nodeOk) problems++;
|
|
59
79
|
|
|
60
80
|
const ccr = hasFn('ccr');
|
|
@@ -74,8 +94,24 @@ function run(opts = {}) {
|
|
|
74
94
|
// temp settings file, so there's no shipped shim asset to check on Windows.
|
|
75
95
|
} else {
|
|
76
96
|
const tmux = hasFn('tmux');
|
|
77
|
-
|
|
78
|
-
|
|
97
|
+
if (!tmux) {
|
|
98
|
+
out.push(warn('tmux missing — needed for the `ccr [profile]` sidebar'));
|
|
99
|
+
problems++;
|
|
100
|
+
} else {
|
|
101
|
+
const v = tmuxVerFn(tmux);
|
|
102
|
+
// An unreadable version is not evidence of a problem — say so and move on
|
|
103
|
+
// rather than manufacturing a failure out of a parse miss.
|
|
104
|
+
if (!v) {
|
|
105
|
+
out.push(ok(`tmux (${stripControl(tmux)})`) + dim(' — version unreadable'));
|
|
106
|
+
} else if (v.major < TMUX_MIN.major
|
|
107
|
+
|| (v.major === TMUX_MIN.major && v.minor < TMUX_MIN.minor)) {
|
|
108
|
+
out.push(warn(`tmux ${v.major}.${v.minor} (${stripControl(tmux)}) — ccr wants `
|
|
109
|
+
+ `${TMUX_MIN.major}.${TMUX_MIN.minor}+; below 3.1 the sidebar split fails outright`));
|
|
110
|
+
problems++;
|
|
111
|
+
} else {
|
|
112
|
+
out.push(ok(`tmux ${v.major}.${v.minor} (${stripControl(tmux)})`));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
79
115
|
out.push(hasFn('bash') ? ok('bash') : warn('bash missing — needed for the launcher'));
|
|
80
116
|
|
|
81
117
|
const sl = path.join(REPO, 'sidecar', 'ccr-statusline');
|
package/src/history-privacy.js
DELETED
|
@@ -1,435 +0,0 @@
|
|
|
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
|
-
};
|