residoo 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,16 +43,16 @@ scanned here left your machine; residoo makes no network calls.
43
43
 
44
44
  That's one snapshot. `residoo watch` runs the same engine continuously and
45
45
  alerts the moment a new secret lands, instead of waiting for you to
46
- remember to scan again; no other tool in the field has anything like it
47
- (see [Watch: continuous scanning](#watch-continuous-scanning)):
48
-
49
- ```
50
- $ residoo watch
51
- watching 43 sources, 118 files · polling every 5s
52
-
53
- 2026-09-03 14:02:11 [high] AWS Access Key ID AKIA****ABCD
54
- claude-code · session-9f2c.jsonl:214 · rf1-8a3e91 Rotate: https://.../access_keys
55
- ```
46
+ remember to scan again. `residoo mcp` lets Claude Code query findings
47
+ conversationally. `residoo cred` removes the reason a credential gets
48
+ pasted into chat in the first place: store it once in your OS keychain,
49
+ run a command with it injected as an environment variable, never typed
50
+ into the conversation at all — which also means a long session compacting
51
+ away the exact value you pasted days ago can't force you to paste it
52
+ again, since there's nothing to lose. `residoo guard` blocks an obviously
53
+ sensitive file read before it happens (100% recall, 0% false positives on
54
+ its own [scored 81-case corpus](bench/guard/RESULTS.md)). All four are
55
+ covered in [docs/features.md](docs/features.md).
56
56
 
57
57
  > [!NOTE]
58
58
  > gitleaks and trufflehog scan **commits**. residoo scans the **conversation
@@ -61,45 +61,39 @@ watching 43 sources, 118 files · polling every 5s
61
61
  > trufflehog/betterleaks' verification postures, in
62
62
  > [docs/comparison.md](docs/comparison.md).
63
63
 
64
- Scan and watch tell you what already leaked. The most common way a NEW
65
- leak happens is pasting a key into the chat so Claude can use it, which
66
- then sits in that conversation's transcript forever, the exact thing scan
67
- exists to catch in the first place. `residoo cred` closes that loop: store
68
- a credential once in your OS keychain, then let Claude run a command with
69
- it injected as an environment variable, never pasted into the chat, never
70
- written into a script (see
71
- [Cred: run commands with injected credentials](#cred-run-commands-with-injected-credentials)):
72
-
73
- ```
74
- $ residoo cred set aws-prod --env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY
75
- Value for AWS_ACCESS_KEY_ID (input hidden):
76
- Value for AWS_SECRET_ACCESS_KEY (input hidden):
77
- Stored credential "aws-prod" (2 env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY).
78
-
79
- $ residoo cred run aws-prod -- aws s3 ls
80
- exit 0 (succeeded). stdout: 3 line(s), stderr: 0 line(s).
81
- Command output is never shown by design, only exit status and line counts.
82
- ```
83
-
84
64
  ## Benchmark: measured, not claimed
85
65
 
86
- A reproducible benchmark against 8 real competing tools, on a synthetic-but-
87
- pattern-true corpus (72 Claude Code sessions, 45 planted credentials, zero
88
- real secrets), with live egress monitoring so "no network calls" is
89
- observed, not just documented. Re-run against every meaningful release,
90
- most recently v0.8.0:
91
-
92
- | | residoo | best of the rest |
93
- |---|---|---|
94
- | Distinct credentials found (all claimed classes) | **45/45 (100%)** | agentsweep 33/42 (79%) |
95
- | Precision (false positives) | **100%** (0 of 55 flags wrong) | gitleaks, whatileaked, trufflehog also 100% |
96
- | Network egress during the scan | **none-observed** | 3 of 8 tools attempt real outbound calls in their *default* mode (trufflehog: 50 connection attempts to github.com, slack.com, api.anthropic.com, gitlab.com, npmjs.org) |
97
-
98
- No single blended score, on purpose: a blend would hide exactly the class-
99
- level differences (base64-wrapped, split-across-lines, JSON-nested) the
100
- benchmark exists to measure. Full per-class breakdown, fairness rules, and
101
- reproduction steps: [bench/](bench/). Self-run, pending independent
102
- reproduction; everything needed to rerun it ships in this repo.
66
+ Scored #1 of 8 real competing tools on a reproducible, synthetic-but-
67
+ pattern-true corpus, with live egress monitoring so "no network calls" is
68
+ observed, not just documented. All 8, not just the closest one:
69
+
70
+ | tool | distinct credentials found | precision | egress during the scan |
71
+ |---|---|---|---|
72
+ | **residoo** | **45/45 (100%)** | **100%** | **none-observed** |
73
+ | agentsweep | 33/42 (79%) | 100% | none-observed |
74
+ | gitleaks | 32/45 (71%) | 100% | none-observed |
75
+ | betterleaks | 32/45 (71%) | 100% | none-observed |
76
+ | whatileaked | 28/42 (67%) | 100% | none-observed |
77
+ | kingfisher | 29/45 (64%) | 100% | attempts calls in default mode |
78
+ | trufflehog | 29/45 (64%) | 97% | attempts calls in default mode |
79
+ | detect-secrets | 25/45 (56%) | 2% | attempts calls in default mode |
80
+
81
+ "none-observed" is a measured result, not a default assumption: every run
82
+ sits under a live proxy trap and process-tree polling, and a deliberate
83
+ canary connection is fired and confirmed caught *before* each real
84
+ benchmark run, specifically so a clean result is falsifiable evidence, not
85
+ silence. The 3 rows with real outbound calls prove the monitor was
86
+ watching them too — kingfisher, trufflehog, and detect-secrets each ship
87
+ an *optional* live-verification feature (checking a found secret against
88
+ the vendor's own API), scored here in their documented offline mode for a
89
+ fair recall comparison, with their default mode's real connection
90
+ attempts reported factually rather than hidden.
91
+
92
+ GitGuardian's `ggshield` is documented, not scored: it refuses to run
93
+ without a server account, so there's no local result to measure. Published
94
+ while losing rows, then fixed in public against the classes it was losing
95
+ — full methodology, every dated rerun, and how to reproduce it yourself:
96
+ [docs/benchmark.md](docs/benchmark.md).
103
97
 
104
98
  ## What it does
105
99
 
@@ -109,245 +103,34 @@ reproduction; everything needed to rerun it ships in this repo.
109
103
  [`src/patterns.js`](src/patterns.js).
110
104
  - Sees through two transcript-specific disguises: a credential dumped only
111
105
  as base64 on a line, or split across two adjacent streaming records, is
112
- decoded/rejoined and rescanned, then reported as `base64-wrapped` or
113
- `split across lines` so you know it was hidden. See
114
- [`src/decode.js`](src/decode.js).
115
- - Pairs an AWS secret access key (no vendor prefix of its own) with a
116
- nearby confirmed access key id and reports both at high confidence: the
117
- *pairing* is the signal, not the shape alone. Ambiguous pairings are
118
- reported as nothing rather than a guess. Same mechanism now also covers
119
- PlanetScale and MongoDB Atlas Service Account credentials. See
120
- [`src/pairing.js`](src/pairing.js).
121
- - Decodes a JWT's own `exp` claim locally (no network call, since the
122
- claim is inside the signed payload) and reports "valid until" or
123
- "expired" instead of just "last seen." See
124
- [`src/jwtExpiry.js`](src/jwtExpiry.js).
106
+ decoded/rejoined and rescanned. See [`src/decode.js`](src/decode.js).
107
+ - Pairs an AWS secret access key with a nearby confirmed access key id
108
+ (also PlanetScale and MongoDB Atlas Service Account credentials) and
109
+ reports both at high confidence; ambiguous pairings are reported as
110
+ nothing rather than a guess. See [`src/pairing.js`](src/pairing.js).
111
+ - Decodes a JWT's own `exp` claim locally and reports "valid until" or
112
+ "expired" instead of just "last seen."
125
113
  - **`--verify`** (opt-in, makes a real network call): asks a credential's
126
- own vendor whether it still authenticates, using the exact value found in
127
- your transcript. 35 vendors today, off by default. See
128
- [Verifying credentials are still live](#verifying-credentials-are-still-live)
129
- below.
130
- - With `--include-noisy`, filters broad generic-secret rules by how
131
- machine-random the matched value looks; never applied to the default
132
- rules. See [`src/rarity.js`](src/rarity.js).
114
+ own vendor whether it still authenticates. 35 vendors today, off by
115
+ default. See [docs/architecture.md](docs/architecture.md#verifying-credentials-are-still-live).
133
116
  - Redacts everything in its own output, including `--json`: you get a
134
117
  shape and a first/last-4 preview, never the real value.
135
- - `--sarif` emits SARIF 2.1.0 for GitHub code scanning; `--json` carries
136
- the full picture (findings, integrity, rotation) together.
137
- - `--seal --keychain` stores the vault key in the OS's own credential store
138
- instead of a typed passphrase. See [Sealing](#sealing-what-it-finds).
118
+ - `--sarif` emits SARIF 2.1.0 for GitHub code scanning.
119
+ - `--seal --keychain` encrypts every transcript with a finding into a
120
+ local vault. See [docs/architecture.md](docs/architecture.md#sealing-what-it-finds).
139
121
  - Tells you how many **distinct** secrets it found versus how many times
140
122
  one got echoed back across tool calls, so the headline number reflects
141
123
  real exposure, not repetition.
142
- - Flags likely placeholder/example matches separately from real findings,
143
- rather than hiding them or inflating the count.
144
124
  - Also scans agent **config files** and checks for **planted persistence**
145
- (hooks, droppers, invisible Unicode); see the next section.
125
+ (hooks, droppers, invisible Unicode) a different, better-documented
126
+ leak surface. See [docs/architecture.md](docs/architecture.md#beyond-transcripts-configs-and-planted-persistence).
146
127
  - Attaches a **rotation runbook** to every finding, plus a local
147
- acknowledgement ledger. See [Rotation](#rotation-from-found-to-closed).
148
- - `--project <dir>` scans a repository checkout instead of the machine, for
149
- CI and pre-commit. See [CI and pre-commit](#ci-and-pre-commit).
150
- - **`residoo watch`**: continuous scanning instead of one snapshot, alerting
151
- the moment a new secret lands in a transcript. See
152
- [Watch: continuous scanning](#watch-continuous-scanning) below.
153
- - **`residoo mcp`**: query findings and manage rotation from inside Claude
154
- Code itself, over a hand-rolled MCP server. See
155
- [MCP: query findings from inside Claude Code](#mcp-query-findings-from-inside-claude-code)
156
- below.
157
- - **`residoo cred`**: store a live credential in the OS keychain and run
158
- one allow-listed command with it injected as an environment variable,
159
- never seen by the caller. See
160
- [Cred: run commands with injected credentials](#cred-run-commands-with-injected-credentials)
161
- below.
162
-
163
- ## Beyond transcripts: configs and planted persistence
164
-
165
- Transcripts leak what your agent *saw*. Config files leak what it was
166
- *configured with*, and that's the better-measured problem: GitGuardian
167
- counted 24,008 secrets inside MCP config files on public GitHub (2,117
168
- still valid), and Lakera found live credentials inside
169
- `.claude/settings.local.json` shipped in ~30 published npm packages. So
170
- `residoo scan` also covers the home-level config files of Claude Code,
171
- Claude Desktop, Cursor, Gemini CLI, Codex, and Kiro, plus project-level
172
- Claude Code configs (`.mcp.json`, `.claude/settings*.json`) resolved from
173
- project roots the agent itself recorded, never by guessing directories.
174
-
175
- Those same files are where 2026's supply-chain campaigns (Mini Shai-Hulud,
176
- Miasma, the keyv/ChainDrop wave, TrapDoor) planted hooks, dropper scripts,
177
- and zero-width-Unicode prompt injection. Every scan now also runs
178
- **integrity checks** over those exact locations:
179
-
180
- - Every auto-executing hook is listed; only a published campaign IOC or
181
- campaign-shaped behavior (piping a download into a shell, base64-decode-
182
- then-execute) escalates to a warning.
183
- - Loose scripts in `.claude/` and known planted filenames are flagged by
184
- name.
185
- - `CLAUDE.md`, `.cursorrules`, and `.cursor/rules/*` are checked for
186
- zero-width Unicode.
187
- - `.vscode/tasks.json` is parsed for folder-open auto-run tasks.
188
-
189
- Read-only like everything else. `--no-integrity` skips it entirely. A
190
- config that can't be read is reported as unverified, never silently
191
- counted clean.
192
-
193
- ## How it works
194
-
195
- ```
196
- YOUR MACHINE · no network calls
197
- ┌───────────────────────────────────────────────────────────────┐
198
- │ │
199
- │ 42 transcript sources agent config files │
200
- │ ~/.claude, Cursor, Codex… settings · MCP · memory │
201
- │ (--project <dir>: a repo checkout instead of the machine) │
202
- │ │ │ │
203
- │ ├──────────────┬───────────────┤ │
204
- │ ▼ │ ▼ │
205
- │ stream + match │ integrity checks │
206
- │ 50 verified rules │ hooks · droppers · │
207
- │ │ │ zero-width unicode │
208
- │ ▼ ▼ │ │
209
- │ redacted report (first/last 4 chars only) ◀────────────┤
210
- │ │ │
211
- │ ├─▶ rotation hints per finding · explain / ack │
212
- │ │ ledger: ~/.residoo/rotations.json │
213
- │ │ │
214
- │ ▼ --seal (only if you ask) │
215
- │ AES-256-GCM vault · scrypt key · encrypted manifest │
216
- │ │ │ │
217
- │ ▼ unseal --restore ▼ --upload-cloudroam
218
- │ SHA-256 verified copy ciphertext only ┄┄┄┄┄┄┄┄┄▶
219
- │ │
220
- └───────────────────────────────────────────────────────────────┘
221
- ```
222
-
223
- The `--seal` and `--upload-cloudroam` legs never run unless you pass their
224
- flag. Nothing in the diagram ever modifies or deletes an existing file. The
225
- one exception, stated in the open: `residoo ack` writes residoo's own
226
- rotation ledger at `~/.residoo/rotations.json` (atomic, redacted, never a
227
- user file).
228
-
229
- ## Sealing what it finds
230
-
231
- Finding a leaked key raises the obvious next question: *now what?*
232
-
233
- ```bash
234
- residoo scan --seal
235
- ```
236
-
237
- Every transcript that carried a finding is encrypted into a local vault
238
- directory: AES-256-GCM, key derived from your passphrase with scrypt,
239
- streamed so an 800MB transcript never touches memory whole. The vault's own
240
- manifest is encrypted too, so it doesn't advertise what's inside even by
241
- name. **Originals are never touched.** Once you've verified a restore works
242
- (`residoo unseal <vault> --restore 0001.sealed --out /tmp/check`, checked
243
- byte-identical via a recorded SHA-256), deleting the plaintext is your
244
- decision, made by you.
245
-
246
- Optionally, `--upload-cloudroam` (with `CLOUDROAM_API_KEY`, `--connector`,
247
- `--bucket`) copies the sealed vault to [CloudRoam](https://cloudroam.io) for
248
- durable, cross-cloud storage.
249
-
250
- > [!IMPORTANT]
251
- > `--upload-cloudroam` is the *only* feature in residoo that touches the
252
- > network to send your data anywhere. It never runs unless you pass the
253
- > flag, and only ciphertext is transmitted: the vault is sealed before any
254
- > upload code executes.
255
-
256
- ## Verifying credentials are still live
257
-
258
- `--verify` asks a credential's own vendor whether it still authenticates,
259
- using the exact value found in your transcript. Off by default, one real
260
- network call per distinct credential.
261
-
262
- Three vendors need a paired id+secret: **AWS** (via `sts:get-caller-identity`,
263
- shelling out to your own `aws` CLI rather than reimplementing request
264
- signing), **PlanetScale**, and **MongoDB Atlas** (Service Account
265
- credentials only, the legacy Public/Private Key pair has no distinguishing
266
- prefix and isn't detected at all). The other 32 are a single credential
267
- each, one direct API call:
268
-
269
- Slack · OpenAI · Anthropic · GitHub · Hugging Face · Replicate ·
270
- DigitalOcean · Pinecone · SendGrid · Groq · xAI · OpenRouter · Stripe · npm ·
271
- Notion · GitLab · Supabase · ElevenLabs · CircleCI · Airtable · Cloudflare ·
272
- Heroku · Netlify · Linear · Telegram · Discord webhooks · Vercel · Cerebras ·
273
- Render · Neon · PostHog · Fly.io
274
-
275
- Every vendor clears the same two-stage bar before being wired up:
276
- independent research against that vendor's own current docs, then a
277
- separate, adversarial pass that tries to refute the first before it's
278
- trusted. A real, sourced reason (no free endpoint, needs context the
279
- credential doesn't carry, or a format not confirmed specifically enough to
280
- detect safely) is why some detected credential types aren't wired to
281
- `--verify` at all, not an oversight (Fly.io's `fm1a_`/`fm1r_`/`fm2_`
282
- "macaroon" tokens are the clearest example: real-machine testing produced a
283
- measured false-positive rate, so that family is detected nowhere in
284
- residoo). A verified-active credential is escalated to "rotate
285
- immediately"; a verified-invalid one is reported already dead, no action
286
- needed. See [`src/verify.js`](src/verify.js).
287
-
288
- ## Rotation: from found to closed
289
-
290
- Detection without rotation is theater: 64% of secrets leaked publicly in
291
- 2022 were still valid years later, 88% of re-verified leaked AWS keys still
292
- authenticated, and the median time to remediate a GitHub-leaked secret is
293
- 94 days. Every finding in a residoo report comes with the way out:
294
-
295
- - **A rotation hint per finding**, from a guidance map covering all 50
296
- detection rules. Where shown, a rotation URL was fetched and confirmed to
297
- document revoking that exact credential type.
298
- - **`residoo explain <rule-id>`** prints the full runbook: where to revoke,
299
- the steps, what revocation does. `residoo explain --list` shows the whole
300
- catalogue.
301
- - **`residoo ack <fingerprint>`** records that you rotated a finding.
302
- **`residoo dismiss <fingerprint>`** records that it was never a real
303
- secret. Both live in `~/.residoo/rotations.json`, residoo's own ledger,
304
- written atomically, redacted through the same pipeline as previews.
305
- - **"Recommended actions" leads the report**: how many *distinct* values
306
- still need a decision, versus how many are already resolved (acked,
307
- dismissed, or `--verify`-confirmed dead).
308
- - **The rotation list groups by credential type**, so the URL prints once
309
- per type. Each value's own line shows its redacted preview, file, and
310
- when it was last seen.
311
- - **Order matters, and the report says so.** The ChainDrop campaign (Aug
312
- 2026) shipped a token monitor that fires an attacker payload the moment a
313
- stolen GitHub token is revoked. When a scan finds both integrity warnings
314
- and leaked credentials, the report tells you to remove the planted
315
- persistence first, rotate second.
316
-
317
- Acks and dismissals change what the report *says*, never what CI *does*:
318
- `--fail-on-find` fails on every finding, resolved or not, unless you pass
319
- `--allow-acked` (integrity warnings always fail either way).
320
-
321
- ## CI and pre-commit
322
-
323
- `residoo scan --project <dir>` scans a repository checkout instead of the
324
- machine it runs on: committed transcripts, agent configs, root `.env`
325
- files, plus integrity checks anchored at that directory. It never touches
326
- the machine's home-level sources, so a clean CI run means the checkout is
327
- clean and claims nothing about anyone's laptop.
328
-
329
- As a GitHub Action (this repo doubles as a composite action):
330
-
331
- ```yaml
332
- steps:
333
- - uses: actions/checkout@v4
334
- - uses: dandovdub/residoo@v0.8.0
335
- ```
336
-
337
- As a pre-commit hook:
338
-
339
- ```yaml
340
- repos:
341
- - repo: https://github.com/dandovdub/residoo
342
- rev: v0.8.0
343
- hooks:
344
- - id: residoo
345
- ```
346
-
347
- Or with no integration at all:
348
- `npm install -g residoo && residoo scan --project . --fail-on-find`.
349
- Exit codes and exactly what project mode does and doesn't see are in
350
- [docs/ci.md](docs/ci.md).
128
+ acknowledgement ledger. See [docs/architecture.md](docs/architecture.md#rotation-from-found-to-closed).
129
+ - `--project <dir>` scans a repository checkout instead of the machine,
130
+ for CI and pre-commit. See [docs/ci.md](docs/ci.md).
131
+ - `residoo watch` / `residoo mcp` / `residoo cred` / `residoo guard`:
132
+ continuous scanning, conversational queries, credential injection
133
+ without pasting, and pre-read blocking. See [docs/features.md](docs/features.md).
351
134
 
352
135
  ## What it does not do
353
136
 
@@ -364,6 +147,13 @@ Exit codes and exactly what project mode does and doesn't see are in
364
147
  just encrypted a copy of.
365
148
  - **No telemetry, no analytics, no update-check ping.**
366
149
 
150
+ Shape-based detection also can't tell a real secret from a realistic-
151
+ looking example in a fetched web page. Three suppression layers narrow the
152
+ gap, none catches every case, and all are re-includable with
153
+ `--include-suppressed`. Treat every finding as a lead to check, not a
154
+ certainty — true of every tool in this category, including the well-
155
+ established ones.
156
+
367
157
  ## Install
368
158
 
369
159
  ```bash
@@ -385,9 +175,10 @@ brew install residoo
385
175
  The Homebrew formula installs the exact tarball published to npm (sha256
386
176
  verified): same bits, not a second build.
387
177
 
388
- Requires Node.js 18+ (22.5+ for the SQLite-backed sources listed below;
389
- residoo still runs fine without it). Zero runtime dependencies: check
390
- `package.json` rather than take that on faith.
178
+ Requires Node.js 18+ (22.5+ for the SQLite-backed sources listed in
179
+ [docs/sources.md](docs/sources.md); residoo still runs fine without it).
180
+ Zero runtime dependencies: check `package.json` rather than take that on
181
+ faith.
391
182
 
392
183
  ## Usage
393
184
 
@@ -406,7 +197,7 @@ residoo scan [options]
406
197
  --no-integrity skip the integrity checks
407
198
  --no-color disable ANSI colour
408
199
  --verify ask each credential's own vendor if it still authenticates
409
- (real network call; see "Verifying credentials are still live")
200
+ (real network call; see docs/architecture.md)
410
201
 
411
202
  --seal encrypt every transcript with findings into a local vault
412
203
  --vault-dir <dir> vault location (default ./residoo-vault-<stamp>)
@@ -420,267 +211,18 @@ residoo ack <fingerprint> [--note <text>] mark one finding rotated
420
211
  residoo unseal <vault-dir> list a vault's contents
421
212
  residoo unseal <vault-dir> --restore <n> --out <p> restore one file, hash-verified
422
213
 
423
- residoo watch [options]
424
-
425
- --interval <seconds> how often to check for new content (default 5, minimum 1)
426
- --json NDJSON events on stdout, one line per finding/re-exposure
427
- --verify same opt-in vendor check as scan --verify, applied to
428
- each newly found credential once
429
- --include-noisy, --include-suppressed, --no-color same meaning as scan
214
+ residoo watch / mcp / cred / guard see docs/features.md
430
215
  ```
431
216
 
432
217
  The vault passphrase comes from `RESIDOO_PASSPHRASE` or a hidden interactive
433
218
  prompt. There is no recovery if you lose it, so pick one you keep.
434
219
 
435
- ## Watch: continuous scanning
436
-
437
- `residoo scan` is a snapshot. `residoo watch` is the same engine run
438
- continuously: it polls every source `scan` already covers, and the moment a
439
- new secret lands in a transcript, prints an alert with the redacted value,
440
- the rule, the file, and the same rotation runbook a scan finding carries,
441
- instead of waiting for you to remember to run `scan` again.
442
-
443
- ```
444
- $ residoo watch
445
- watching 43 sources, 118 files (61 tailed, 57 rescanned on change)
446
- polling every 5s; fs.watch is not used, every alert comes from polling
447
-
448
- 2026-09-03 14:02:11 [high] AWS Access Key ID AKIA****ABCD
449
- claude-code · session-9f2c.jsonl:214 · rf1-8a3e91 Rotate: https://.../access_keys
450
- ```
451
-
452
- It is watch-from-**now**: run `residoo scan` first for anything already on
453
- disk, since a fresh `residoo watch` baselines whatever it finds on its first
454
- sweep silently and only alerts on content written after it starts. A finding
455
- already acknowledged or dismissed (`residoo ack` / `residoo dismiss`) stays
456
- suppressed, checked against the same `~/.residoo/rotations.json` ledger, and
457
- a ledger change made mid-watch takes effect within one poll interval, no
458
- restart needed. A findings-free sweep prints nothing at all, including to
459
- its own watched Claude Code session, by design: no other tool in this
460
- project's own benchmark ([`bench/`](bench/)) has a continuous mode at all,
461
- verified directly against each one's own `--help` output rather than
462
- assumed; see [docs/comparison.md](docs/comparison.md) for how the one
463
- adjacent thing, GitGuardian's `ggshield` AI hook, works differently.
464
-
465
- ## MCP: query findings from inside Claude Code
466
-
467
- `residoo mcp` runs residoo as an MCP server over stdio, so Claude Code can
468
- query findings and manage the rotation ledger conversationally instead of
469
- you running the CLI in a terminal:
470
-
471
- ```bash
472
- claude mcp add residoo -- residoo mcp
473
- ```
474
-
475
- or run `scripts/install-mcp.sh` (also in this repo), which installs residoo
476
- itself first if it isn't already, then registers it and verifies the
477
- connection.
478
-
479
- or add it directly to `.mcp.json`:
480
-
481
- ```json
482
- {
483
- "mcpServers": {
484
- "residoo": { "type": "stdio", "command": "residoo", "args": ["mcp"] }
485
- }
486
- }
487
- ```
488
-
489
- Five read-only tools, mirroring the CLI exactly: `residoo_scan` (a fresh
490
- scan, merged with rotation status), `residoo_check` (only what's new
491
- since the last check in this conversation, backed by the same engine as
492
- `watch`), `residoo_explain` (a rule's rotation runbook), and
493
- `residoo_ack` / `residoo_dismiss` (append to the local ledger). Every
494
- value returned is redacted the same way the CLI's own output is; nothing
495
- here makes a network call or touches the transcript files themselves.
496
- Like the rest of residoo, this is hand-rolled against the MCP spec
497
- directly, not built on `@modelcontextprotocol/sdk`: zero runtime
498
- dependencies stays true here too. A sixth, opt-in tool exists for
499
- injected-credential execution, covered below.
500
-
501
- A seventh tool, `residoo_verify_finding`, is genuinely different from the
502
- other six: it asks a credential's own vendor, live, whether it's still
503
- active (the same mechanism as `scan --verify`, scoped to exactly one
504
- credential per call). This is the one MCP tool that makes a real network
505
- call, so it does not exist at all unless you set
506
- `RESIDOO_MCP_ALLOW_VERIFY=1` in the environment `residoo mcp` runs in — a
507
- default install stays true to "zero network calls" without a caveat.
508
- Once enabled, pass a `fingerprint` from a prior `residoo_scan`; you get
509
- back `active`, `invalid`, or `unknown`, never the raw value. Paired
510
- credentials (AWS access key + secret, PlanetScale, MongoDB Atlas) aren't
511
- supported yet — use `residoo scan --verify` from a terminal for those.
512
-
513
- ## Cred: run commands with injected credentials
514
-
515
- The usual way an AI coding agent ends up able to use a real credential is
516
- you pasting it into the chat, which puts it in that conversation's
517
- transcript forever, indistinguishable from any other leak `residoo scan`
518
- finds. `residoo cred` is the alternative: store the credential once in
519
- your OS keychain, then let Claude run one allow-listed command with it
520
- injected as environment variables. Claude never sees the raw value,
521
- before, during, or after, and it's never written into a script either.
522
-
523
- ```bash
524
- residoo cred set aws-prod --env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY
525
- # hidden-typed input, once per --env flag; interactive TTY only, no
526
- # scripted entry, since a live credential is more sensitive than a vault
527
- # passphrase and should never be typeable into a script or env var.
528
-
529
- RESIDOO_CRED_ALLOWED_COMMANDS="aws=/usr/local/bin/aws" residoo mcp
530
- # now residoo_run_with_cred exists as an MCP tool; it does not exist at
531
- # all (won't appear in the tool list) until this is set.
532
-
533
- residoo cred run aws-prod -- aws s3 ls
534
- # same operation from a terminal, for testing without an MCP client.
535
- ```
536
-
537
- **Why this is safer than it looks, stated precisely, not just asserted:**
538
-
539
- - `RESIDOO_CRED_ALLOWED_COMMANDS` (`name=/absolute/path,...`) is an
540
- environment variable the operator sets outside any conversation, read
541
- fresh on every single invocation. Empty or unset means **nothing may
542
- run, by design**: this is the actual, sole security boundary.
543
- - The `command` a caller (human or model) supplies is used **only as a
544
- lookup key** into that map, never as a path, never resolved via
545
- `PATH`. This was not always true: a first draft matched by binary
546
- *name* alone (checked fresh every call, but only verifying the name the
547
- caller claimed, not the binary that actually ran), and an adversarial
548
- review found two concrete ways around that: a caller-supplied path
549
- smuggled straight past the check, and a same-named malicious binary
550
- planted earlier on the server process's own inherited `PATH`. Both are
551
- closed structurally now: `command` cannot cause any file other than the
552
- operator-pinned absolute path to execute, full stop.
553
- - Arguments are always a structured list, never a shell string, so no
554
- shell metacharacter ever gets interpreted.
555
- - The executed command's own stdout/stderr content is **never returned**,
556
- only exit status and line counts, because that output is itself a
557
- channel the injected secret could leak through in ways no redaction
558
- pass can guarantee to catch (an echoed variable, a stack trace).
559
- - A hung command is killed after 30 seconds, not configurable by the
560
- caller (letting a model choose its own timeout has no legitimate use
561
- and only helps an attacker keep a process alive longer).
562
- - One line goes to `residoo mcp`'s own **stderr** per credential use
563
- (timestamp, credential name, command, exit code, never the value or
564
- the arguments). This is **not durable by default**: redirect the MCP
565
- server's stderr at launch (`residoo mcp 2>> ~/.residoo-cred-audit.log`,
566
- or your MCP client's equivalent) if you want a persistent trail.
567
-
568
- **Only ever allow-list narrow, single-purpose CLIs**, never a tool that
569
- can itself run arbitrary third-party code as part of normal operation
570
- (`npm`, `npx`, `pip`, `make`, `cargo`, any build tool). Watch out even for
571
- a seemingly narrow tool with its own plugin system: an allow-listed `gh`
572
- still receives the injected credential as an inherited environment
573
- variable in whatever `gh extension exec` or `gh alias` runs, which is
574
- untrusted the moment it's a third-party extension. This is a residual
575
- risk allow-listing alone doesn't remove, so narrow the tools you allow-list
576
- accordingly, and prefer credentials scoped as tightly as the vendor
577
- allows.
578
-
579
- Storage is macOS (`security`) or Linux (`secret-tool`) only, matching
580
- `--seal --keychain`'s own existing platform support; Windows is refused
581
- with a clear message rather than half-built. There is no `residoo cred
582
- list` in v1: you need to already know the name you set.
583
-
584
- ## Guard: block a sensitive read before it happens
585
-
586
- Everything above finds a leak after it's already written to disk. `residoo
587
- guard` is the one piece of residoo that tries to stop one from happening in
588
- the first place — a Claude Code `PreToolUse` hook that blocks an obviously-
589
- sensitive file read (`.env`, `id_rsa`, `.aws/credentials`, and similar)
590
- before the command runs at all.
591
-
592
- ```json
593
- {
594
- "hooks": {
595
- "PreToolUse": [
596
- { "matcher": "Bash|Read", "hooks": [{ "type": "command", "command": "residoo guard" }] }
597
- ]
598
- }
599
- }
600
- ```
601
- in `.claude/settings.json`. It reads one hook payload from stdin and writes
602
- a deny decision to stdout only when the proposed command or file path
603
- matches; anything it doesn't recognize falls through untouched, with zero
604
- output, exit 0.
605
-
606
- This is narrower than it might sound, and the gap is worth stating
607
- plainly rather than implying more than it does: Claude Code's hooks API
608
- lets a `PreToolUse` hook see the proposed tool INPUT (a Bash command
609
- string, a Read file path) before it runs, but there is no documented
610
- mechanism for a hook to see or redact a tool's OUTPUT — by the time a
611
- `PostToolUse` hook fires, that output is already committed to the
612
- transcript. So this can only block on the shape of the request, never
613
- clean up what a command already printed. It will not catch a secret typed
614
- directly into a prompt, or one arriving in the output of an otherwise
615
- unremarkable command (`curl`, a build log). `residoo scan` / `watch` /
616
- `mcp` remain the actual safety net; this is a best-effort tripwire on top
617
- of them, not a replacement.
618
-
619
220
  ## Sources supported today
620
221
 
621
- 43 sources: 42 transcript stores plus the agent-config source above, in two
622
- honestly-distinct tiers. `--project` adds one more, opt-in source that
623
- scans a checkout instead of the machine.
624
-
625
- **Real-install-verified**: run against an actual, populated installation
626
- and confirmed to find real content: **Claude Code**
627
- (`~/.claude/projects/**/*.jsonl`) and **agent config files** for its
628
- Claude-family paths.
629
-
630
- **Multi-source-corroborated-but-unverified**: backed by 2+ independent,
631
- credible sources but not checked against a real install on any machine this
632
- project was built on. Still built to fail loudly rather than silently
633
- report "all clear":
634
-
635
- Cursor, Codex CLI, OpenCode, Aider, Cline, Roo Code, Kilo Code, Windsurf,
636
- PearAI, Trae, Void, Gemini CLI, Qwen Code, Continue, Open Interpreter,
637
- Goose, GitHub Copilot Chat/CLI, `llm`, Codebuff, Mentat, Hermes, OpenClaw,
638
- Warp, Crush, Grok Build, Kiro CLI/IDE, Zed, JetBrains Junie/AI Assistant,
639
- Sourcegraph Cody, Amazon Q Developer, Qodo Gen, OpenHands, Factory Droid
640
- CLI, Devin CLI, Pi, Google Antigravity, Kimi Code, and `fx`.
641
-
642
- A few are SQLite-backed (Cursor, Crush, Cody, Devin CLI, Hermes, Kiro CLI,
643
- `llm`, Trae, Void, Warp, Zed) and need Node.js 22.5+ for the built-in
644
- `node:sqlite` module. On an older Node, each reports as detected-but-not-
645
- scanned rather than silently dropping.
646
-
647
- **Investigated and deliberately not included:** Plandex (client-server,
648
- nothing local to scan), CodeGPT and Augment Code (account/cloud-based, no
649
- local transcript file), Replit Agent (cloud-only). Tabby, Tabnine,
650
- Zencoder, Tongyi Lingma, and Berd didn't clear the 2-independent-source bar
651
- in the time available. A verified adapter for any of these is a welcome PR.
652
-
653
- See [`src/sources/index.js`](src/sources/index.js) for the full list, and
654
- each source file's own header for exactly what was and wasn't checked.
655
-
656
- ## Adding a source
657
-
658
- A source is a small object with four methods: `id()`, `label()`,
659
- `available()`, `files()`, `readLines(file)`.
660
- [`src/sources/claude-code.js`](src/sources/claude-code.js) is the reference
661
- implementation: copy it, point it at your tool's real local storage path,
662
- open a PR. Two contracts worth getting right:
663
-
664
- - **`files()`** yields `{ file, mtimeMs, sizeBytes, broken }`. Set
665
- `broken: true` for an entry that looked scannable but wasn't (a dangling
666
- symlink); don't just skip past it silently.
667
- - **`readLines(file)`** is `async`, returning `{ lines, status, bytesRead }`
668
- with `status` one of `"complete"`, `"partial"`, `"too-large"`, `"failed"`.
669
- Whatever's in `lines` for a non-`"complete"` status still gets scanned.
670
-
671
- Please verify the path actually exists and holds real content before
672
- submitting.
673
-
674
- ## A known limitation, stated plainly
675
-
676
- Shape-based detection can't tell a real secret from a realistic-looking
677
- example in a fetched web page or documentation your agent read back to
678
- you. Three suppression layers narrow the gap (known vendor-documented
679
- example values, placeholder bodies built from one repeated character, and
680
- placeholder-looking surrounding context), but none catches every case, and
681
- all are re-includable with `--include-suppressed`. Treat every finding as a
682
- lead to check, not a certainty. The same is true of every tool in this
683
- category, including the well-established ones.
222
+ 43 sources, real-install-verified for Claude Code and its config family,
223
+ multi-source-corroborated for the rest (Cursor, Codex CLI, Cline, Windsurf,
224
+ Gemini CLI, Copilot, and 30+ more). Full list, what "corroborated" means,
225
+ and how to add one: [docs/sources.md](docs/sources.md).
684
226
 
685
227
  ## License
686
228
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "CloudRoam (https://cloudroam.io)",
@@ -20,7 +20,8 @@
20
20
  "node": ">=18"
21
21
  },
22
22
  "scripts": {
23
- "test": "node tests/smoke.js"
23
+ "test": "node tests/smoke.js",
24
+ "fuzz": "node tests/fuzz.js"
24
25
  },
25
26
  "files": [
26
27
  "bin",
@@ -41,5 +42,8 @@
41
42
  "privacy",
42
43
  "cli",
43
44
  "encryption"
44
- ]
45
+ ],
46
+ "devDependencies": {
47
+ "fast-check": "4.9.0"
48
+ }
45
49
  }
package/src/guard.js CHANGED
@@ -39,18 +39,45 @@ const BOUNDARY = "(?:$|[\\s'\"`;|&)<>])";
39
39
  // Left boundary: start of string, a path separator (mid-path, e.g.
40
40
  // "/foo/.env"), OR whitespace/a shell metacharacter (the path is one
41
41
  // argument in a longer command, e.g. "cat .env && echo done" -- ".env" is
42
- // preceded by a space, not a separator).
42
+ // preceded by a space, not a separator). Deliberately NOT a hyphen: an
43
+ // earlier version added one (to catch "gcp-service-account-prod.json",
44
+ // see the dedicated pattern below instead) and it broke the public.pem/
45
+ // public.key exclusions below -- a negative lookahead only guards its own
46
+ // anchor position, and a hyphen boundary let the regex engine start
47
+ // matching again from a LATER position inside the same filename (e.g.
48
+ // right after "public-" in "public-key.pem"), silently walking around the
49
+ // exclusion. Kept narrow and per-pattern instead of widening this shared
50
+ // primitive for one case.
43
51
  const SEP = "(?:^|[\\s'\"`;|&(<>\\\\/])";
44
52
  const pat = (body) => new RegExp(SEP + body + BOUNDARY, "i");
45
53
 
54
+ // Suffixes that make a .env-shaped path a committed, secret-free template
55
+ // rather than the real thing: never planted with live credentials by
56
+ // convention, and reading one is completely routine (checking which vars
57
+ // a project needs). Found by testing this guard against realistic dev
58
+ // commands, not assumed: cat .env.example was blocked before this existed.
59
+ const ENV_SAFE_SUFFIX = "example|sample|template|dist|default|schema";
60
+
46
61
  const SENSITIVE_PATH_PATTERNS = [
47
- // dotenv files, including staged/numbered variants (.env.local, .env.1)
48
- { re: pat("\\.env(?:\\.[\\w.-]+)?"), label: "a .env file" },
49
- // SSH private keys: the conventional default names, and any *.pem/*.key
50
- { re: pat("id_(?:rsa|dsa|ecdsa|ed25519)(?:\\.pub)?"), label: "an SSH private key" },
51
- { re: new RegExp(SEP + "\\.ssh[\\\\/]", "i"), label: "the SSH directory" },
52
- { re: pat("[\\w.-]+\\.pem"), label: "a .pem key file" },
53
- { re: pat("[\\w.-]+\\.key"), label: "a .key file" },
62
+ // dotenv files, including staged/numbered variants (.env.local, .env.1),
63
+ // but not a known-safe template suffix (see ENV_SAFE_SUFFIX above).
64
+ { re: pat(`\\.env(?:\\.(?!(?:${ENV_SAFE_SUFFIX})(?:$|[\\s'"\`;|&)<>.]))[\\w.-]+)?`), label: "a .env file" },
65
+ // SSH private keys: the conventional default names. Deliberately NOT
66
+ // *.pub -- a public key is, by definition, meant to be shared (it's
67
+ // what you paste into GitHub's own SSH keys page); blocking its read
68
+ // protects nothing and was a real false positive found the same way.
69
+ { re: pat("id_(?:rsa|dsa|ecdsa|ed25519)"), label: "an SSH private key" },
70
+ // The whole .ssh directory, EXCEPT its own *.pub files and known_hosts
71
+ // (host key fingerprints, not credentials -- reading it can't expose
72
+ // anything) -- same public-key/not-actually-sensitive principle as the
73
+ // id_/*.pem/*.key exclusions above, applied to a directory match.
74
+ { re: new RegExp(SEP + "\\.ssh[\\\\/](?!(?:[\\w.-]+\\.pub|known_hosts(?:\\.old)?)(?:$|[\\s'\"`;|&)<>]))", "i"), label: "the SSH directory" },
75
+ // *.pem/*.key, except a filename that itself says "public": a real
76
+ // private key is never conventionally named that way, and "public.pem"/
77
+ // "public-key.pem" naming a non-sensitive cert is common enough that
78
+ // blocking it is pure noise, not protection.
79
+ { re: pat("(?!public[-_.])[\\w.-]+\\.pem"), label: "a .pem key file" },
80
+ { re: pat("(?!public[-_.])[\\w.-]+\\.key"), label: "a .key file" },
54
81
  // cloud / vendor credential files with a fixed, well-known name
55
82
  { re: pat("\\.aws[\\\\/](?:credentials|config)"), label: "the AWS credentials file" },
56
83
  { re: pat("\\.netrc"), label: "the .netrc file" },
@@ -60,7 +87,14 @@ const SENSITIVE_PATH_PATTERNS = [
60
87
  { re: pat("\\.kube[\\\\/]config"), label: "the kubeconfig file" },
61
88
  { re: pat("application_default_credentials\\.json"), label: "gcloud application-default credentials" },
62
89
  { re: pat("credentials\\.json"), label: "a credentials.json file" },
63
- { re: pat("service[_-]?account[\\w.-]*\\.json"), label: "a GCP service-account key file" },
90
+ // No SEP prefix here, on purpose: a real, common naming convention
91
+ // prefixes this with a project/company name and a hyphen (e.g.
92
+ // "gcp-service-account-prod.json"), which the standard SEP boundary
93
+ // (start/whitespace/separator, deliberately not a hyphen -- see SEP's
94
+ // own comment) would miss entirely. "service[_-]?account" is specific
95
+ // enough as a token that not requiring a left boundary here is a safe,
96
+ // narrow exception rather than a reason to widen SEP itself.
97
+ { re: new RegExp("service[_-]?account[\\w.-]*\\.json" + BOUNDARY, "i"), label: "a GCP service-account key file" },
64
98
  { re: pat("secrets?\\.(?:json|ya?ml)"), label: "a secrets file" },
65
99
  ];
66
100
 
package/src/patterns.js CHANGED
@@ -145,11 +145,16 @@ const PATTERNS = [
145
145
  // Confirmed via airtable.com/developers/web/api: pat + 14 alnum + "." + 64 hex.
146
146
  { id: "airtable_token", label: "Airtable personal access token", confidence: "high",
147
147
  re: /\bpat[A-Za-z0-9]{14}\.[a-f0-9]{64}\b/g },
148
- // Current Cloudflare API Token format only (cfat_/cfut_, confirmed via
149
- // developers.cloudflare.com). The legacy format is a bare 40-char string
150
- // with no prefix, left out for the same reason as CircleCI's legacy form.
148
+ // Current Cloudflare credential formats only (cfat_/cfut_/cfk_, all three
149
+ // confirmed via developers.cloudflare.com/fundamentals/api/get-started/token-formats,
150
+ // which describes all three with the identical "<prefix>_[40 characters]
151
+ // [checksum]" shape). cfk_ (Global API Key, full account access) found
152
+ // missing by cross-checking agentsweep's own open issue tracker -- it had
153
+ // cfat_/cfut_ before this project did, cfk_ after. The legacy formats are
154
+ // bare unprefixed strings, left out for the same reason as CircleCI's
155
+ // legacy form.
151
156
  { id: "cloudflare_api_token", label: "Cloudflare API token", confidence: "high",
152
- re: /\bcf[au]t_[a-zA-Z0-9]{40}[a-f0-9]{8}\b/g },
157
+ re: /\bcf(?:[au]t|k)_[a-zA-Z0-9]{40}[a-f0-9]{8}\b/g },
153
158
  // Current Heroku API key format only (HRKU-AA + 58 chars, confirmed via
154
159
  // Heroku's own help docs). The legacy format is a bare UUID, left out:
155
160
  // "any UUID-shaped string" is exactly the noisy, unspecific shape this