claude-code-runrate 0.3.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.
- package/CHANGELOG.md +114 -0
- package/README.md +107 -12
- package/bin/ccr.js +233 -26
- package/package.json +8 -2
- package/scripts/launch.sh +34 -5
- package/src/account-limits.js +21 -13
- package/src/doctor.js +34 -3
- package/src/git-history.js +273 -0
- package/src/git-ignore.js +118 -0
- package/src/git-index.js +167 -0
- package/src/git-objects.js +448 -0
- package/src/git-repo.js +294 -0
- package/src/git-working-tree.js +266 -0
- package/src/history-privacy.js +435 -0
- package/src/instance-name.js +182 -0
- package/src/instance-resolve.js +116 -0
- package/src/instance-slot.js +433 -0
- package/src/launch-vscode.js +65 -6
- package/src/launch-win.js +202 -11
- package/src/migrate.js +155 -0
- package/src/pane-config.js +40 -6
- package/src/render/economy.js +30 -5
- package/src/render/git-pane.js +345 -0
- package/src/render/shared.js +69 -1
- package/src/render/statusline.js +50 -5
- package/src/safe-read.js +18 -2
- package/src/session-log.js +116 -0
- package/src/sidecar-keys.js +167 -0
- package/src/sidecar.js +174 -27
- package/src/state-dir.js +61 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/instance-name.js — choose an instance's friendly name
|
|
4
|
+
// (features/instance-naming.feature). Identity is the slot number; the name
|
|
5
|
+
// is a label on top.
|
|
6
|
+
//
|
|
7
|
+
// THE MAPPING IS LOAD-BEARING FOR SECURITY, not cosmetics. The autoname
|
|
8
|
+
// derives from a directory name, `git clone` names the directory after the
|
|
9
|
+
// repo, and the name reaches the terminal title inside an OSC escape — and
|
|
10
|
+
// some terminals can echo the title back as terminal INPUT, so a repo named
|
|
11
|
+
// "; rm -rf ~" is plain ASCII that no control-byte blocklist touches.
|
|
12
|
+
// Constraining the CHARACTER SET is the guard (src/sanitize.js strips control
|
|
13
|
+
// bytes but is a blocklist; this is an allow-list). Nobody may relax it as
|
|
14
|
+
// cosmetic.
|
|
15
|
+
//
|
|
16
|
+
// The asymmetry, ruled: a DERIVED name is mapped (the user didn't choose
|
|
17
|
+
// it); an EXPLICIT name is rejected (they typed it, and a human is right
|
|
18
|
+
// there to fix it). Collision handling differs the same way: a derived name
|
|
19
|
+
// takes the lowest free suffix among LIVE names; an explicit collision is
|
|
20
|
+
// refused — silently suffixing it would make the address the user typed
|
|
21
|
+
// resolve to someone else's instance.
|
|
22
|
+
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
|
|
26
|
+
const NAME_CHAR_RE = /[A-Za-z0-9._-]/;
|
|
27
|
+
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
28
|
+
const NAME_FILE = 'name';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Map an arbitrary directory basename into the allowed set: anything outside
|
|
32
|
+
* [A-Za-z0-9._-] becomes '-'. When the SOURCE had no legal character at all,
|
|
33
|
+
* the slot number is the name (a dir named "---" keeps its dashes — they are
|
|
34
|
+
* legal; a dir named "###" has nothing to keep).
|
|
35
|
+
*
|
|
36
|
+
* @param {string} source
|
|
37
|
+
* @param {number} slot
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function mapName(source, slot) {
|
|
41
|
+
let kept = 0;
|
|
42
|
+
const mapped = [...String(source)].map((c) => (NAME_CHAR_RE.test(c) ? (kept++, c) : '-')).join('');
|
|
43
|
+
return kept > 0 ? mapped : String(slot);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The autoname: the repository's directory name when the launch dir is inside
|
|
48
|
+
* a repo (with the bare-repo `.git` correction the pane already applies),
|
|
49
|
+
* else the launch dir's own basename — both through the mapping.
|
|
50
|
+
*
|
|
51
|
+
* @param {{ cwd: string, slot: number }} o
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function deriveName(o) {
|
|
55
|
+
let base = path.basename(o.cwd);
|
|
56
|
+
try {
|
|
57
|
+
const found = require('./git-repo').discoverRepo(o.cwd);
|
|
58
|
+
if (found && found.found && found.root) {
|
|
59
|
+
base = path.basename(found.root);
|
|
60
|
+
if (base === '.git') base = path.basename(path.dirname(found.root));
|
|
61
|
+
}
|
|
62
|
+
} catch { /* fall back to the cwd basename */ }
|
|
63
|
+
return mapName(base, o.slot);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Names of the LIVE instances — the only set names must be unique within.
|
|
68
|
+
* Ephemerality does the reaping: a dead instance's dir (and its name file)
|
|
69
|
+
* is deleted, so its name is simply absent here.
|
|
70
|
+
*
|
|
71
|
+
* @param {{ home: string, inspect?: (dir: string) => {live: boolean, attached: boolean} }} o
|
|
72
|
+
* @returns {Set<string>}
|
|
73
|
+
*/
|
|
74
|
+
function liveNames(o) {
|
|
75
|
+
const inspect = o.inspect || require('./instance-slot').defaultInspect;
|
|
76
|
+
const root = path.join(o.home, '.ccr', 'instances');
|
|
77
|
+
/** @type {Set<string>} */
|
|
78
|
+
const names = new Set();
|
|
79
|
+
let entries; try { entries = fs.readdirSync(root); } catch { return names; }
|
|
80
|
+
for (const n of entries) {
|
|
81
|
+
if (!/^\d+$/.test(n)) continue;
|
|
82
|
+
const dir = path.join(root, n);
|
|
83
|
+
try {
|
|
84
|
+
if (!inspect(dir).live) continue;
|
|
85
|
+
const name = fs.readFileSync(path.join(dir, NAME_FILE), 'utf8').trim();
|
|
86
|
+
if (name) names.add(name);
|
|
87
|
+
} catch { /* unnamed or unreadable — nothing to reserve */ }
|
|
88
|
+
}
|
|
89
|
+
return names;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The collision suffix: per-name lowest free among live names, INDEPENDENT of
|
|
94
|
+
* the slot number — with "gitrepo" on slot 1 and "gatrepo" on slot 2, the next
|
|
95
|
+
* gitrepo lands on slot 3 but is named "gitrepo2", never "gitrepo3". The
|
|
96
|
+
* generator checks ALL live names, so a real directory named "gitrepo2"
|
|
97
|
+
* cannot collide with a generated suffix.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} base
|
|
100
|
+
* @param {Set<string>} live
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function withSuffix(base, live) {
|
|
104
|
+
if (!live.has(base)) return base;
|
|
105
|
+
for (let k = 2; ; k++) {
|
|
106
|
+
const candidate = `${base}${k}`;
|
|
107
|
+
if (!live.has(candidate)) return candidate;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Record the chosen name in the instance's own state — instance-scoped, so it
|
|
113
|
+
* dies with the session, which is what frees the name.
|
|
114
|
+
* @param {string} stateDir @param {string} name
|
|
115
|
+
*/
|
|
116
|
+
function recordName(stateDir, name) {
|
|
117
|
+
try { fs.writeFileSync(path.join(stateDir, NAME_FILE), name + '\n', { mode: 0o600 }); } catch { /* best effort */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The LIVE location half of the status-line identity: the repository's name
|
|
122
|
+
* when `cwd` is inside one, else the directory's own basename — through the
|
|
123
|
+
* same allow-list mapping as names (this string reaches the same terminal the
|
|
124
|
+
* title does). Null when nothing legal survives or cwd is unusable: the
|
|
125
|
+
* identity then shows the name alone.
|
|
126
|
+
*
|
|
127
|
+
* @param {string|undefined|null} cwd
|
|
128
|
+
* @returns {string|null}
|
|
129
|
+
*/
|
|
130
|
+
function locationFrom(cwd) {
|
|
131
|
+
if (!cwd || typeof cwd !== 'string') return null;
|
|
132
|
+
let base = path.basename(cwd);
|
|
133
|
+
try {
|
|
134
|
+
const found = require('./git-repo').discoverRepo(cwd);
|
|
135
|
+
if (found && found.found && found.root) {
|
|
136
|
+
base = path.basename(found.root);
|
|
137
|
+
if (base === '.git') base = path.basename(path.dirname(found.root));
|
|
138
|
+
}
|
|
139
|
+
} catch { /* fall back to the basename */ }
|
|
140
|
+
let kept = 0;
|
|
141
|
+
const mapped = [...base].map((c) => (NAME_CHAR_RE.test(c) ? (kept++, c) : '-')).join('');
|
|
142
|
+
return kept > 0 ? mapped : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The terminal title: `[profile / ]name`, composed ONCE at launch and never
|
|
147
|
+
* retitled — the title is the tab's ADDRESS (it must keep matching the name
|
|
148
|
+
* -i accepts), while the pane and status line are the surfaces honest about
|
|
149
|
+
* mid-session movement. Both inputs are already constrained (profile by the
|
|
150
|
+
* launcher's allow-list, name by NAME_RE), so the title needs no escaping.
|
|
151
|
+
*
|
|
152
|
+
* @param {string|undefined} profile
|
|
153
|
+
* @param {string} name
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
function composeTitle(profile, name) {
|
|
157
|
+
return profile ? `${profile} / ${name}` : name;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Everything an allocated slot needs to become a NAMED instance — shared by
|
|
162
|
+
* all three launchers so no platform ships half-lit: derive (or take) the
|
|
163
|
+
* name, record it and the profile in the instance dir, compose the title.
|
|
164
|
+
* Explicit-name validation and the collision refusal happen BEFORE launch
|
|
165
|
+
* routing (bin/ccr.js), so by the time a slot exists the name is legal.
|
|
166
|
+
*
|
|
167
|
+
* @param {{ slot: number, stateDir: string }} slot
|
|
168
|
+
* @param {{ profile?: string, name?: string|null, cwd?: string, home?: string }} [o]
|
|
169
|
+
* @returns {{ name: string, title: string }}
|
|
170
|
+
*/
|
|
171
|
+
function prepareInstance(slot, o = {}) {
|
|
172
|
+
const home = o.home || require('node:os').homedir();
|
|
173
|
+
const name = o.name != null ? o.name
|
|
174
|
+
: withSuffix(deriveName({ cwd: o.cwd || process.cwd(), slot: slot.slot }), liveNames({ home }));
|
|
175
|
+
recordName(slot.stateDir, name);
|
|
176
|
+
if (o.profile) {
|
|
177
|
+
try { fs.writeFileSync(path.join(slot.stateDir, 'profile'), o.profile + '\n', { mode: 0o600 }); } catch { /* best effort */ }
|
|
178
|
+
}
|
|
179
|
+
return { name, title: composeTitle(o.profile, name) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { NAME_RE, NAME_FILE, mapName, deriveName, liveNames, withSuffix, recordName, locationFrom, composeTitle, prepareInstance };
|