residoo 0.1.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/LICENSE +21 -0
- package/README.md +185 -0
- package/SECURITY.md +75 -0
- package/bin/residoo.js +20 -0
- package/package.json +19 -0
- package/src/cli.js +211 -0
- package/src/patterns.js +109 -0
- package/src/prompt.js +30 -0
- package/src/report.js +123 -0
- package/src/scan.js +163 -0
- package/src/sealcrypto.js +149 -0
- package/src/sealvault.js +111 -0
- package/src/sources/claude-code.js +180 -0
- package/src/sources/index.js +23 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const { createInterface } = require("readline/promises");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const os = require("os");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Claude Code session transcripts.
|
|
10
|
+
*
|
|
11
|
+
* One JSONL file per session, one JSON object per line, under
|
|
12
|
+
* ~/.claude/projects/<project-slug>/<session-id>.jsonl. This is the only
|
|
13
|
+
* source shipped in v1 — it's the one path we could verify actually exists
|
|
14
|
+
* and actually holds real transcript content, rather than a guessed path
|
|
15
|
+
* for a tool we didn't have installed to check against. See CONTRIBUTING.md
|
|
16
|
+
* for how to add a source for another tool.
|
|
17
|
+
*/
|
|
18
|
+
const ROOT = path.join(os.homedir(), ".claude", "projects");
|
|
19
|
+
|
|
20
|
+
// Bounds for readLines(), see the docstring below for why both exist.
|
|
21
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB — generous headroom over the
|
|
22
|
+
// largest real transcript this
|
|
23
|
+
// tool has been tested against (818MB).
|
|
24
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
function id() { return "claude-code"; }
|
|
27
|
+
function label() { return "Claude Code"; }
|
|
28
|
+
|
|
29
|
+
function available() {
|
|
30
|
+
try { return fs.statSync(ROOT).isDirectory(); } catch { return false; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Dirent.isDirectory()/isFile() reflect the entry ITSELF (lstat semantics) —
|
|
35
|
+
* for a symlink they both return false, even when the link resolves to a
|
|
36
|
+
* real directory or file. A dotfiles manager or a project relocated onto a
|
|
37
|
+
* symlink would then be silently excluded from scanning with no indication
|
|
38
|
+
* anything was skipped. statSync (unlike lstatSync) follows the link, so
|
|
39
|
+
* only symlinks fall through to it — the common case stays lstat-only and
|
|
40
|
+
* cheap.
|
|
41
|
+
*
|
|
42
|
+
* The tradeoff, stated plainly rather than left implicit: following symlinks
|
|
43
|
+
* here means residoo will read whatever a `*.jsonl`-named symlink under
|
|
44
|
+
* ~/.claude/projects points at, not only files Claude Code itself wrote.
|
|
45
|
+
* Before this, lstat semantics accidentally sandboxed every scan to real
|
|
46
|
+
* transcript files; that sandbox is intentionally traded away for the
|
|
47
|
+
* relocated-project case. Do not place a symlink to a sensitive file inside
|
|
48
|
+
* ~/.claude/projects. See SECURITY.md.
|
|
49
|
+
*/
|
|
50
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
51
|
+
if (checkFn(dirent)) return true;
|
|
52
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
53
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
54
|
+
}
|
|
55
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
56
|
+
const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Yield { file, mtimeMs, sizeBytes, broken } for every session transcript
|
|
60
|
+
* found — including ones that could not actually be resolved.
|
|
61
|
+
*
|
|
62
|
+
* `broken: true` (mtimeMs/sizeBytes absent) marks a `.jsonl`-named entry, or
|
|
63
|
+
* a project directory, that looked like it should be scannable but wasn't —
|
|
64
|
+
* chiefly a dangling symlink (a real, plausible case: dotfiles-sync tools
|
|
65
|
+
* and moved home directories both produce these). The earlier version of
|
|
66
|
+
* this fix followed valid symlinks correctly but let a BROKEN one fall
|
|
67
|
+
* through its own try/catch and `continue` silently, inside this generator,
|
|
68
|
+
* before the caller ever saw it — reintroducing, for exactly the entries
|
|
69
|
+
* most likely to need it, the same silent exclusion this whole feature was
|
|
70
|
+
* built to end. Every entry this function decides not to scan is now
|
|
71
|
+
* reported, one way or another.
|
|
72
|
+
*/
|
|
73
|
+
function* files() {
|
|
74
|
+
let projectDirs;
|
|
75
|
+
try { projectDirs = fs.readdirSync(ROOT, { withFileTypes: true }); }
|
|
76
|
+
catch { return; }
|
|
77
|
+
|
|
78
|
+
for (const proj of projectDirs) {
|
|
79
|
+
const dir = path.join(ROOT, proj.name);
|
|
80
|
+
if (!proj.isDirectory()) {
|
|
81
|
+
const resolved = isDirFollowingSymlink(dir, proj);
|
|
82
|
+
// Only a symlink that fails to resolve is a reportable failure — a
|
|
83
|
+
// stray non-directory entry that was never meant to be a project
|
|
84
|
+
// folder in the first place is silently out of scope, same as before.
|
|
85
|
+
if (!resolved) {
|
|
86
|
+
if (proj.isSymbolicLink()) yield { file: dir, broken: true };
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let entries;
|
|
91
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
92
|
+
catch { yield { file: dir, broken: true }; continue; }
|
|
93
|
+
|
|
94
|
+
for (const e of entries) {
|
|
95
|
+
if (!e.name.endsWith(".jsonl")) continue;
|
|
96
|
+
const file = path.join(dir, e.name);
|
|
97
|
+
if (!e.isFile()) {
|
|
98
|
+
const resolved = isFileFollowingSymlink(file, e);
|
|
99
|
+
if (!resolved) {
|
|
100
|
+
if (e.isSymbolicLink()) yield { file, broken: true };
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
let stat;
|
|
105
|
+
try { stat = fs.statSync(file); } catch { yield { file, broken: true }; continue; }
|
|
106
|
+
yield { file, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read one transcript as an array of raw text lines.
|
|
113
|
+
*
|
|
114
|
+
* Streams line-by-line via readline/promises rather than `readFileSync +
|
|
115
|
+
* split` — verified this matters, not a theoretical concern: a real 818MB
|
|
116
|
+
* session on a real machine this tool was tested against threw
|
|
117
|
+
* `ERR_STRING_TOO_LONG` under the whole-file-as-one-string approach (V8's
|
|
118
|
+
* ~512M-character single-string limit). To be precise about what this fixes
|
|
119
|
+
* and what it doesn't: individual JSONL lines stay far under that ceiling,
|
|
120
|
+
* so line-by-line reading removes the crash — it does NOT reduce peak
|
|
121
|
+
* memory, since every line is still collected into one array before
|
|
122
|
+
* returning. A true bounded-memory version would match patterns against
|
|
123
|
+
* each line as it arrives instead of collecting first; not done here
|
|
124
|
+
* because no transcript observed so far makes that the binding constraint.
|
|
125
|
+
*
|
|
126
|
+
* Re-stats the file immediately before opening it — yes, files() already
|
|
127
|
+
* stat'd it once. That's deliberate, not an oversight: re-checking right
|
|
128
|
+
* before open narrows the TOCTOU window between "we decided this looks
|
|
129
|
+
* like a readable file" and "we actually opened it," which matters more
|
|
130
|
+
* here than saving one syscall, given the entry may be a symlink whose
|
|
131
|
+
* target isn't guaranteed stable between the two points.
|
|
132
|
+
*
|
|
133
|
+
* Returns { lines, status, bytesRead }. `status` is "complete", "partial"
|
|
134
|
+
* (some lines WERE read before a failure — scan them, don't discard real
|
|
135
|
+
* content just because the file didn't finish cleanly), "too-large", or
|
|
136
|
+
* "failed" (nothing could be read at all — e.g. deleted between the files()
|
|
137
|
+
* walk and this call, plausible mid-scan if Claude Code is actively
|
|
138
|
+
* writing). Callers should scan `lines` whenever present, and treat
|
|
139
|
+
* anything other than "complete" as worth surfacing to the user rather
|
|
140
|
+
* than silently folding into a clean report.
|
|
141
|
+
*/
|
|
142
|
+
async function readLines(file) {
|
|
143
|
+
let stat;
|
|
144
|
+
try { stat = fs.statSync(file); }
|
|
145
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
146
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
147
|
+
|
|
148
|
+
const lines = [];
|
|
149
|
+
let bytesRead = 0;
|
|
150
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
151
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
152
|
+
|
|
153
|
+
// No natural timeout exists anywhere in Node's stream/readline stack. A
|
|
154
|
+
// symlink whose target changes between the stat above and this open
|
|
155
|
+
// (e.g. retargeted onto a FIFO with no writer) can make the underlying
|
|
156
|
+
// open() block forever with no 'error', 'line', or 'close' ever firing —
|
|
157
|
+
// destroying the stream is what actually unblocks that. Without this, one
|
|
158
|
+
// hostile or merely unlucky file hangs the entire CLI, no way out.
|
|
159
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
for await (const line of rl) {
|
|
163
|
+
lines.push(line);
|
|
164
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
|
|
165
|
+
}
|
|
166
|
+
return { lines, status: "complete", bytesRead };
|
|
167
|
+
} catch {
|
|
168
|
+
// Whatever WAS read before the failure is real content and may contain
|
|
169
|
+
// a real secret — discarding it because the file didn't finish cleanly
|
|
170
|
+
// would be a silent false negative, which is worse than an honest
|
|
171
|
+
// "partial" label.
|
|
172
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
173
|
+
} finally {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
rl.close();
|
|
176
|
+
stream.destroy();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { id, label, available, files, readLines };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Registry of transcript sources. Each source is a small adapter exposing
|
|
5
|
+
* { id, label, available, files, readLines } — see claude-code.js for the
|
|
6
|
+
* reference implementation and CONTRIBUTING.md for how to add one.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately NOT included here: guessed paths for Cursor, GitHub Copilot,
|
|
9
|
+
* or Windsurf. Their local history formats are real but weren't verified
|
|
10
|
+
* against an actual installation while building this — shipping a scanner
|
|
11
|
+
* that silently checks the wrong path and reports "all clear" is worse than
|
|
12
|
+
* not supporting the tool at all. PRs adding a verified adapter are the
|
|
13
|
+
* fastest way to get a tool covered.
|
|
14
|
+
*/
|
|
15
|
+
const claudeCode = require("./claude-code");
|
|
16
|
+
|
|
17
|
+
const ALL_SOURCES = [claudeCode];
|
|
18
|
+
|
|
19
|
+
function availableSources() {
|
|
20
|
+
return ALL_SOURCES.filter((s) => s.available());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { ALL_SOURCES, availableSources };
|