residoo 0.7.2 → 0.8.1

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,21 @@ 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.7.2:
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:
91
69
 
92
70
  | | residoo | best of the rest |
93
71
  |---|---|---|
94
72
  | Distinct credentials found (all claimed classes) | **45/45 (100%)** | agentsweep 33/42 (79%) |
95
73
  | 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) |
74
+ | Network egress during the scan | **none-observed** | 3 of 8 tools attempt real outbound calls in their *default* mode |
97
75
 
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.
76
+ Published while losing rows, then fixed in public against the classes it
77
+ was losing — full methodology, every dated rerun, and how to reproduce it
78
+ yourself: [docs/benchmark.md](docs/benchmark.md).
103
79
 
104
80
  ## What it does
105
81
 
@@ -109,245 +85,34 @@ reproduction; everything needed to rerun it ships in this repo.
109
85
  [`src/patterns.js`](src/patterns.js).
110
86
  - Sees through two transcript-specific disguises: a credential dumped only
111
87
  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).
88
+ decoded/rejoined and rescanned. See [`src/decode.js`](src/decode.js).
89
+ - Pairs an AWS secret access key with a nearby confirmed access key id
90
+ (also PlanetScale and MongoDB Atlas Service Account credentials) and
91
+ reports both at high confidence; ambiguous pairings are reported as
92
+ nothing rather than a guess. See [`src/pairing.js`](src/pairing.js).
93
+ - Decodes a JWT's own `exp` claim locally and reports "valid until" or
94
+ "expired" instead of just "last seen."
125
95
  - **`--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).
96
+ own vendor whether it still authenticates. 35 vendors today, off by
97
+ default. See [docs/architecture.md](docs/architecture.md#verifying-credentials-are-still-live).
133
98
  - Redacts everything in its own output, including `--json`: you get a
134
99
  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).
100
+ - `--sarif` emits SARIF 2.1.0 for GitHub code scanning.
101
+ - `--seal --keychain` encrypts every transcript with a finding into a
102
+ local vault. See [docs/architecture.md](docs/architecture.md#sealing-what-it-finds).
139
103
  - Tells you how many **distinct** secrets it found versus how many times
140
104
  one got echoed back across tool calls, so the headline number reflects
141
105
  real exposure, not repetition.
142
- - Flags likely placeholder/example matches separately from real findings,
143
- rather than hiding them or inflating the count.
144
106
  - Also scans agent **config files** and checks for **planted persistence**
145
- (hooks, droppers, invisible Unicode); see the next section.
107
+ (hooks, droppers, invisible Unicode) a different, better-documented
108
+ leak surface. See [docs/architecture.md](docs/architecture.md#beyond-transcripts-configs-and-planted-persistence).
146
109
  - 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.7.2
335
- ```
336
-
337
- As a pre-commit hook:
338
-
339
- ```yaml
340
- repos:
341
- - repo: https://github.com/dandovdub/residoo
342
- rev: v0.7.2
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).
110
+ acknowledgement ledger. See [docs/architecture.md](docs/architecture.md#rotation-from-found-to-closed).
111
+ - `--project <dir>` scans a repository checkout instead of the machine,
112
+ for CI and pre-commit. See [docs/ci.md](docs/ci.md).
113
+ - `residoo watch` / `residoo mcp` / `residoo cred` / `residoo guard`:
114
+ continuous scanning, conversational queries, credential injection
115
+ without pasting, and pre-read blocking. See [docs/features.md](docs/features.md).
351
116
 
352
117
  ## What it does not do
353
118
 
@@ -364,6 +129,13 @@ Exit codes and exactly what project mode does and doesn't see are in
364
129
  just encrypted a copy of.
365
130
  - **No telemetry, no analytics, no update-check ping.**
366
131
 
132
+ Shape-based detection also can't tell a real secret from a realistic-
133
+ looking example in a fetched web page. Three suppression layers narrow the
134
+ gap, none catches every case, and all are re-includable with
135
+ `--include-suppressed`. Treat every finding as a lead to check, not a
136
+ certainty — true of every tool in this category, including the well-
137
+ established ones.
138
+
367
139
  ## Install
368
140
 
369
141
  ```bash
@@ -385,9 +157,10 @@ brew install residoo
385
157
  The Homebrew formula installs the exact tarball published to npm (sha256
386
158
  verified): same bits, not a second build.
387
159
 
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.
160
+ Requires Node.js 18+ (22.5+ for the SQLite-backed sources listed in
161
+ [docs/sources.md](docs/sources.md); residoo still runs fine without it).
162
+ Zero runtime dependencies: check `package.json` rather than take that on
163
+ faith.
391
164
 
392
165
  ## Usage
393
166
 
@@ -406,7 +179,7 @@ residoo scan [options]
406
179
  --no-integrity skip the integrity checks
407
180
  --no-color disable ANSI colour
408
181
  --verify ask each credential's own vendor if it still authenticates
409
- (real network call; see "Verifying credentials are still live")
182
+ (real network call; see docs/architecture.md)
410
183
 
411
184
  --seal encrypt every transcript with findings into a local vault
412
185
  --vault-dir <dir> vault location (default ./residoo-vault-<stamp>)
@@ -420,216 +193,18 @@ residoo ack <fingerprint> [--note <text>] mark one finding rotated
420
193
  residoo unseal <vault-dir> list a vault's contents
421
194
  residoo unseal <vault-dir> --restore <n> --out <p> restore one file, hash-verified
422
195
 
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
196
+ residoo watch / mcp / cred / guard see docs/features.md
430
197
  ```
431
198
 
432
199
  The vault passphrase comes from `RESIDOO_PASSPHRASE` or a hidden interactive
433
200
  prompt. There is no recovery if you lose it, so pick one you keep.
434
201
 
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 add it directly to `.mcp.json`:
476
-
477
- ```json
478
- {
479
- "mcpServers": {
480
- "residoo": { "type": "stdio", "command": "residoo", "args": ["mcp"] }
481
- }
482
- }
483
- ```
484
-
485
- Five read-only tools, mirroring the CLI exactly: `residoo_scan` (a fresh
486
- scan, merged with rotation status), `residoo_check` (only what's new
487
- since the last check in this conversation, backed by the same engine as
488
- `watch`), `residoo_explain` (a rule's rotation runbook), and
489
- `residoo_ack` / `residoo_dismiss` (append to the local ledger). Every
490
- value returned is redacted the same way the CLI's own output is; nothing
491
- here makes a network call or touches the transcript files themselves.
492
- Like the rest of residoo, this is hand-rolled against the MCP spec
493
- directly, not built on `@modelcontextprotocol/sdk`: zero runtime
494
- dependencies stays true here too. A sixth, opt-in tool exists for
495
- injected-credential execution, covered below.
496
-
497
- ## Cred: run commands with injected credentials
498
-
499
- The usual way an AI coding agent ends up able to use a real credential is
500
- you pasting it into the chat, which puts it in that conversation's
501
- transcript forever, indistinguishable from any other leak `residoo scan`
502
- finds. `residoo cred` is the alternative: store the credential once in
503
- your OS keychain, then let Claude run one allow-listed command with it
504
- injected as environment variables. Claude never sees the raw value,
505
- before, during, or after, and it's never written into a script either.
506
-
507
- ```bash
508
- residoo cred set aws-prod --env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY
509
- # hidden-typed input, once per --env flag; interactive TTY only, no
510
- # scripted entry, since a live credential is more sensitive than a vault
511
- # passphrase and should never be typeable into a script or env var.
512
-
513
- RESIDOO_CRED_ALLOWED_COMMANDS="aws=/usr/local/bin/aws" residoo mcp
514
- # now residoo_run_with_cred exists as an MCP tool; it does not exist at
515
- # all (won't appear in the tool list) until this is set.
516
-
517
- residoo cred run aws-prod -- aws s3 ls
518
- # same operation from a terminal, for testing without an MCP client.
519
- ```
520
-
521
- **Why this is safer than it looks, stated precisely, not just asserted:**
522
-
523
- - `RESIDOO_CRED_ALLOWED_COMMANDS` (`name=/absolute/path,...`) is an
524
- environment variable the operator sets outside any conversation, read
525
- fresh on every single invocation. Empty or unset means **nothing may
526
- run, by design**: this is the actual, sole security boundary.
527
- - The `command` a caller (human or model) supplies is used **only as a
528
- lookup key** into that map, never as a path, never resolved via
529
- `PATH`. This was not always true: a first draft matched by binary
530
- *name* alone (checked fresh every call, but only verifying the name the
531
- caller claimed, not the binary that actually ran), and an adversarial
532
- review found two concrete ways around that: a caller-supplied path
533
- smuggled straight past the check, and a same-named malicious binary
534
- planted earlier on the server process's own inherited `PATH`. Both are
535
- closed structurally now: `command` cannot cause any file other than the
536
- operator-pinned absolute path to execute, full stop.
537
- - Arguments are always a structured list, never a shell string, so no
538
- shell metacharacter ever gets interpreted.
539
- - The executed command's own stdout/stderr content is **never returned**,
540
- only exit status and line counts, because that output is itself a
541
- channel the injected secret could leak through in ways no redaction
542
- pass can guarantee to catch (an echoed variable, a stack trace).
543
- - A hung command is killed after 30 seconds, not configurable by the
544
- caller (letting a model choose its own timeout has no legitimate use
545
- and only helps an attacker keep a process alive longer).
546
- - One line goes to `residoo mcp`'s own **stderr** per credential use
547
- (timestamp, credential name, command, exit code, never the value or
548
- the arguments). This is **not durable by default**: redirect the MCP
549
- server's stderr at launch (`residoo mcp 2>> ~/.residoo-cred-audit.log`,
550
- or your MCP client's equivalent) if you want a persistent trail.
551
-
552
- **Only ever allow-list narrow, single-purpose CLIs**, never a tool that
553
- can itself run arbitrary third-party code as part of normal operation
554
- (`npm`, `npx`, `pip`, `make`, `cargo`, any build tool). Watch out even for
555
- a seemingly narrow tool with its own plugin system: an allow-listed `gh`
556
- still receives the injected credential as an inherited environment
557
- variable in whatever `gh extension exec` or `gh alias` runs, which is
558
- untrusted the moment it's a third-party extension. This is a residual
559
- risk allow-listing alone doesn't remove, so narrow the tools you allow-list
560
- accordingly, and prefer credentials scoped as tightly as the vendor
561
- allows.
562
-
563
- Storage is macOS (`security`) or Linux (`secret-tool`) only, matching
564
- `--seal --keychain`'s own existing platform support; Windows is refused
565
- with a clear message rather than half-built. There is no `residoo cred
566
- list` in v1: you need to already know the name you set.
567
-
568
202
  ## Sources supported today
569
203
 
570
- 43 sources: 42 transcript stores plus the agent-config source above, in two
571
- honestly-distinct tiers. `--project` adds one more, opt-in source that
572
- scans a checkout instead of the machine.
573
-
574
- **Real-install-verified**: run against an actual, populated installation
575
- and confirmed to find real content: **Claude Code**
576
- (`~/.claude/projects/**/*.jsonl`) and **agent config files** for its
577
- Claude-family paths.
578
-
579
- **Multi-source-corroborated-but-unverified**: backed by 2+ independent,
580
- credible sources but not checked against a real install on any machine this
581
- project was built on. Still built to fail loudly rather than silently
582
- report "all clear":
583
-
584
- Cursor, Codex CLI, OpenCode, Aider, Cline, Roo Code, Kilo Code, Windsurf,
585
- PearAI, Trae, Void, Gemini CLI, Qwen Code, Continue, Open Interpreter,
586
- Goose, GitHub Copilot Chat/CLI, `llm`, Codebuff, Mentat, Hermes, OpenClaw,
587
- Warp, Crush, Grok Build, Kiro CLI/IDE, Zed, JetBrains Junie/AI Assistant,
588
- Sourcegraph Cody, Amazon Q Developer, Qodo Gen, OpenHands, Factory Droid
589
- CLI, Devin CLI, Pi, Google Antigravity, Kimi Code, and `fx`.
590
-
591
- A few are SQLite-backed (Cursor, Crush, Cody, Devin CLI, Hermes, Kiro CLI,
592
- `llm`, Trae, Void, Warp, Zed) and need Node.js 22.5+ for the built-in
593
- `node:sqlite` module. On an older Node, each reports as detected-but-not-
594
- scanned rather than silently dropping.
595
-
596
- **Investigated and deliberately not included:** Plandex (client-server,
597
- nothing local to scan), CodeGPT and Augment Code (account/cloud-based, no
598
- local transcript file), Replit Agent (cloud-only). Tabby, Tabnine,
599
- Zencoder, Tongyi Lingma, and Berd didn't clear the 2-independent-source bar
600
- in the time available. A verified adapter for any of these is a welcome PR.
601
-
602
- See [`src/sources/index.js`](src/sources/index.js) for the full list, and
603
- each source file's own header for exactly what was and wasn't checked.
604
-
605
- ## Adding a source
606
-
607
- A source is a small object with four methods: `id()`, `label()`,
608
- `available()`, `files()`, `readLines(file)`.
609
- [`src/sources/claude-code.js`](src/sources/claude-code.js) is the reference
610
- implementation: copy it, point it at your tool's real local storage path,
611
- open a PR. Two contracts worth getting right:
612
-
613
- - **`files()`** yields `{ file, mtimeMs, sizeBytes, broken }`. Set
614
- `broken: true` for an entry that looked scannable but wasn't (a dangling
615
- symlink); don't just skip past it silently.
616
- - **`readLines(file)`** is `async`, returning `{ lines, status, bytesRead }`
617
- with `status` one of `"complete"`, `"partial"`, `"too-large"`, `"failed"`.
618
- Whatever's in `lines` for a non-`"complete"` status still gets scanned.
619
-
620
- Please verify the path actually exists and holds real content before
621
- submitting.
622
-
623
- ## A known limitation, stated plainly
624
-
625
- Shape-based detection can't tell a real secret from a realistic-looking
626
- example in a fetched web page or documentation your agent read back to
627
- you. Three suppression layers narrow the gap (known vendor-documented
628
- example values, placeholder bodies built from one repeated character, and
629
- placeholder-looking surrounding context), but none catches every case, and
630
- all are re-includable with `--include-suppressed`. Treat every finding as a
631
- lead to check, not a certainty. The same is true of every tool in this
632
- category, including the well-established ones.
204
+ 43 sources, real-install-verified for Claude Code and its config family,
205
+ multi-source-corroborated for the rest (Cursor, Codex CLI, Cline, Windsurf,
206
+ Gemini CLI, Copilot, and 30+ more). Full list, what "corroborated" means,
207
+ and how to add one: [docs/sources.md](docs/sources.md).
633
208
 
634
209
  ## License
635
210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
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)",
package/src/cli.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  const { startWatch, isTailable } = require("./watch");
14
14
  const { startMcpServer } = require("./mcp");
15
15
  const { buildTools } = require("./mcpTools");
16
+ const { runGuard: runGuardEngine } = require("./guard");
16
17
 
17
18
  /**
18
19
  * A source is unavailable for the ordinary reason (not installed — nothing
@@ -162,6 +163,10 @@ MCP:
162
163
  with "claude mcp add residoo -- residoo mcp".
163
164
  Zero runtime dependencies: the protocol is hand-
164
165
  rolled, not the official SDK.
166
+ A 7th tool, residoo_verify_finding, asks a credential's own vendor, live,
167
+ whether it's still active -- the one MCP tool that makes a real network
168
+ call, so it does not exist unless RESIDOO_MCP_ALLOW_VERIFY=1 is set in the
169
+ server's own environment. See the README for the full scope and limits.
165
170
 
166
171
  Cred:
167
172
  residoo cred set <name> --env <ENV_VAR_NAME> [--env <ENV_VAR_NAME_2> ...]
@@ -191,6 +196,26 @@ Cred:
191
196
  residoo mcp exposes the same operation as the residoo_run_with_cred
192
197
  tool, present only when RESIDOO_CRED_ALLOWED_COMMANDS is configured.
193
198
 
199
+ Guard:
200
+ residoo guard a Claude Code PreToolUse hook that blocks an
201
+ obviously-sensitive file read (.env, id_rsa,
202
+ .aws/credentials, and similar) before it can be
203
+ written to the session transcript at all --
204
+ prevention, not just detection. Reads one hook
205
+ payload from stdin, writes a deny decision to
206
+ stdout only when it matches; never blocks on
207
+ anything it doesn't recognize. This is narrower
208
+ than it sounds: Claude Code's hooks API can see a
209
+ proposed Bash command or Read path before it
210
+ runs, but never the command's OUTPUT, so this
211
+ cannot catch a secret typed into a prompt or one
212
+ arriving through an unrelated command's output --
213
+ scan/watch/mcp remain the real safety net. Add to
214
+ .claude/settings.json:
215
+ {"hooks":{"PreToolUse":[{"matcher":"Bash|Read",
216
+ "hooks":[{"type":"command",
217
+ "command":"residoo guard"}]}]}}
218
+
194
219
  Rotation:
195
220
  residoo explain <rule-id> full rotation runbook for one detection rule
196
221
  (where to revoke, steps, what revocation does)
@@ -806,6 +831,7 @@ async function main(argv) {
806
831
  if (cmd === "watch") return runWatch(args);
807
832
  if (cmd === "mcp") return runMcp(args);
808
833
  if (cmd === "cred") return runCred(args);
834
+ if (cmd === "guard") return runGuardEngine();
809
835
  if (cmd !== "scan") {
810
836
  process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
811
837
  return 2;
package/src/guard.js ADDED
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `residoo guard`: a Claude Code PreToolUse hook that blocks an obviously-
5
+ * sensitive file read before it happens, instead of finding the leak in the
6
+ * transcript afterward.
7
+ *
8
+ * Scope, stated plainly because it is much narrower than "prevent secrets
9
+ * from leaking": Claude Code's hooks API gives a PreToolUse hook the
10
+ * PROPOSED tool input (a Bash command string, a Read file_path) before the
11
+ * tool runs, and lets it deny the call outright -- but it never sees the
12
+ * tool's OUTPUT, and by the time a PostToolUse hook fires, that output is
13
+ * already committed to the transcript and can no longer be redacted. There
14
+ * is no documented hook mechanism for "let the read happen, but strip the
15
+ * secret out of what the model sees." So this can only block INPUT that
16
+ * matches a known-sensitive file path pattern (.env, id_rsa, .aws/credentials,
17
+ * and similar) -- it cannot catch a secret typed directly into a prompt, a
18
+ * secret arriving in the output of an otherwise-unremarkable command
19
+ * (curl, a build log), or any file path this pattern list does not name.
20
+ * `residoo scan`/`watch`/`mcp` remain the actual safety net; this is a
21
+ * narrower, best-effort tripwire on top, not a replacement for them.
22
+ *
23
+ * Fails safe in the direction of NOT blocking on any uncertainty: a
24
+ * malformed hook payload, an unrecognized tool name, or a parse error all
25
+ * fall through to "allow" (no stdout, exit 0) rather than denying a call
26
+ * this module does not understand. The one thing this module must never do
27
+ * is silently hang or crash the agent's turn over a tool call that was
28
+ * always going to be fine.
29
+ */
30
+
31
+ // A matched path fragment must be preceded by a path separator or the start
32
+ // of the string, and followed by either the end of the string (the common
33
+ // case for Read's file_path) or a shell metacharacter/whitespace (the case
34
+ // for a Bash command string, where the path is one argument among several,
35
+ // e.g. "cat .env && echo done"). Applying this uniformly, rather than a
36
+ // bespoke `$`-anchor per pattern, is what makes every entry below work
37
+ // identically for both tool_input shapes.
38
+ const BOUNDARY = "(?:$|[\\s'\"`;|&)<>])";
39
+ // Left boundary: start of string, a path separator (mid-path, e.g.
40
+ // "/foo/.env"), OR whitespace/a shell metacharacter (the path is one
41
+ // argument in a longer command, e.g. "cat .env && echo done" -- ".env" is
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.
51
+ const SEP = "(?:^|[\\s'\"`;|&(<>\\\\/])";
52
+ const pat = (body) => new RegExp(SEP + body + BOUNDARY, "i");
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
+
61
+ const SENSITIVE_PATH_PATTERNS = [
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" },
81
+ // cloud / vendor credential files with a fixed, well-known name
82
+ { re: pat("\\.aws[\\\\/](?:credentials|config)"), label: "the AWS credentials file" },
83
+ { re: pat("\\.netrc"), label: "the .netrc file" },
84
+ { re: pat("\\.npmrc"), label: "the .npmrc file (may hold a publish token)" },
85
+ { re: pat("\\.git-credentials"), label: "the git-credentials file" },
86
+ { re: pat("\\.docker[\\\\/]config\\.json"), label: "the Docker config (may hold registry auth)" },
87
+ { re: pat("\\.kube[\\\\/]config"), label: "the kubeconfig file" },
88
+ { re: pat("application_default_credentials\\.json"), label: "gcloud application-default credentials" },
89
+ { re: pat("credentials\\.json"), label: "a credentials.json 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" },
98
+ { re: pat("secrets?\\.(?:json|ya?ml)"), label: "a secrets file" },
99
+ ];
100
+
101
+ /** True if `text` (a file path, or a whole shell command string) contains a recognizable sensitive-path match. Returns the matched label or null. */
102
+ function matchSensitivePath(text) {
103
+ if (typeof text !== "string" || !text) return null;
104
+ for (const { re, label } of SENSITIVE_PATH_PATTERNS) {
105
+ if (re.test(text)) return label;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ const GUARDED_TOOL_NAMES = new Set(["Bash", "Read"]);
111
+
112
+ /**
113
+ * Pure decision function: given a PreToolUse hook payload's tool_name and
114
+ * tool_input, decide whether to block. No I/O, fully unit-testable.
115
+ */
116
+ function evaluateToolInput(toolName, toolInput) {
117
+ if (!GUARDED_TOOL_NAMES.has(toolName) || !toolInput || typeof toolInput !== "object") {
118
+ return { block: false, reason: null };
119
+ }
120
+ const candidate = toolName === "Bash" ? toolInput.command : toolInput.file_path;
121
+ const label = matchSensitivePath(candidate);
122
+ if (!label) return { block: false, reason: null };
123
+ return {
124
+ block: true,
125
+ reason: `residoo guard: this looks like a read of ${label}. Blocked before it could be written to the session transcript. ` +
126
+ `If this is intentional and safe, ask the human to read it themselves, or disable this hook in .claude/settings.json.`,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Reads one PreToolUse hook payload from `input` (default stdin), decides,
132
+ * and writes the hook's own JSON response protocol to `output` (default
133
+ * stdout) -- exit code is the caller's job (bin/residoo.js), this returns
134
+ * the intended process exit code instead of calling process.exit itself,
135
+ * matching every other run* function in cli.js.
136
+ */
137
+ async function runGuard({ input = process.stdin, output = process.stdout } = {}) {
138
+ const chunks = [];
139
+ for await (const chunk of input) chunks.push(chunk);
140
+ const raw = Buffer.concat(chunks.map((c) => (Buffer.isBuffer(c) ? c : Buffer.from(c)))).toString("utf-8");
141
+
142
+ let payload;
143
+ try {
144
+ payload = JSON.parse(raw);
145
+ } catch {
146
+ return 0; // malformed payload: fail open, never block on something we can't parse
147
+ }
148
+
149
+ const decision = evaluateToolInput(payload.tool_name, payload.tool_input);
150
+ if (!decision.block) return 0;
151
+
152
+ output.write(JSON.stringify({
153
+ hookSpecificOutput: {
154
+ hookEventName: "PreToolUse",
155
+ permissionDecision: "deny",
156
+ permissionDecisionReason: decision.reason,
157
+ },
158
+ }) + "\n");
159
+ return 0;
160
+ }
161
+
162
+ module.exports = { evaluateToolInput, matchSensitivePath, runGuard, SENSITIVE_PATH_PATTERNS };
package/src/mcpTools.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const path = require("path");
4
- const { scan } = require("./scan");
4
+ const { scan, VERIFIABLE_RULE_IDS } = require("./scan");
5
5
  const {
6
6
  ROTATION_GUIDANCE, guidanceFor, loadAcks, loadDismissed,
7
7
  ackFinding, dismissFinding, renderRotation,
@@ -20,13 +20,17 @@ const keychain = require("./keychain");
20
20
  * human-facing presenters), and this file's whole job is to never let a
21
21
  * byte reach stdout except through mcp.js's own `send()`.
22
22
  *
23
- * `verify` is not exposed as a parameter on ANY tool here, on purpose: a
24
- * human typing `--verify` at a terminal is a deliberate, legible act; an
25
- * autonomous model choosing a network-triggering parameter mid-
26
- * conversation is a different trust boundary, and a generic tool-approval
27
- * prompt may not surface that a given call also makes a live vendor API
28
- * request with a real secret. Every `scan()`/`sweepOnce()` call below
29
- * hardcodes `verify: false`.
23
+ * `verify` is not exposed as a parameter on residoo_scan/residoo_check, on
24
+ * purpose: a human typing `--verify` at a terminal is a deliberate, legible
25
+ * act; an autonomous model choosing a network-triggering parameter
26
+ * mid-conversation is a different trust boundary, and a generic
27
+ * tool-approval prompt may not surface that a given call also makes a live
28
+ * vendor API request with a real secret. Both hardcode `verify: false`. Live
29
+ * verification instead gets its own narrowly-scoped tool, residoo_verify_finding
30
+ * (one credential per call, gated behind RESIDOO_MCP_ALLOW_VERIFY so it does
31
+ * not exist at all unless an operator deliberately opts in) -- see its own
32
+ * comment below for why that is a materially different, honestly-labeled
33
+ * trust boundary rather than the same one wearing a different name.
30
34
  */
31
35
 
32
36
  const FINGERPRINT_PATTERN = /^rf1-[0-9a-f]{32}$/;
@@ -283,6 +287,62 @@ function buildTools({ sources }) {
283
287
  });
284
288
  }
285
289
 
290
+ async function handleVerifyFinding(args) {
291
+ const errs = rejectUnknownKeys(args, new Set(["fingerprint"]));
292
+ if (typeof args.fingerprint !== "string") {
293
+ errs.push("fingerprint is required and must be a string");
294
+ } else if (!FINGERPRINT_PATTERN.test(args.fingerprint)) {
295
+ errs.push("fingerprint must match ^rf1-[0-9a-f]{32}$ -- copy it verbatim from a prior residoo_scan/residoo_check result, never construct or guess one");
296
+ }
297
+ if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
298
+
299
+ const findEntry = async () => {
300
+ const result = await scan({ sources, includeNoisy: true, includeSuppressed: true, verify: false, noColor: true });
301
+ const rotation = renderRotation(result.findings, loadAcks(), loadDismissed());
302
+ return rotation.entries.find((e) => e.fingerprint === args.fingerprint) || null;
303
+ };
304
+
305
+ const before = await findEntry();
306
+ if (!before) {
307
+ return textResult({
308
+ fingerprint: args.fingerprint, found: false, verifiable: null, verified: null,
309
+ summary: "No finding with this fingerprint is currently on disk. It may have been resolved, the source file may have changed since it was last seen, or you may need to call residoo_scan first to see current fingerprints.",
310
+ });
311
+ }
312
+ if (!VERIFIABLE_RULE_IDS.has(before.ruleId)) {
313
+ return textResult({
314
+ fingerprint: args.fingerprint, found: true, ruleId: before.ruleId, verifiable: false, verified: null,
315
+ summary: `residoo cannot live-verify a ${before.ruleId} credential yet. Paired credentials (AWS, PlanetScale, MongoDB Atlas) and credential types with no vendor whoami-style endpoint (JWTs, private keys, bearer tokens of unknown origin, connection strings) are not supported by this tool. Run "residoo scan --project <dir> --verify" from a terminal for AWS/PlanetScale/MongoDB Atlas pairs.`,
316
+ });
317
+ }
318
+
319
+ // The actual network call: scoped so ONLY this one fingerprint's
320
+ // credential is ever queued for verification inside scan() (see
321
+ // verifyOnlyFingerprint in src/scan.js), regardless of how many other
322
+ // verifiable credentials exist on this machine. This is the whole reason
323
+ // this tool takes one fingerprint and not a list.
324
+ const result = await scan({ sources, includeNoisy: true, includeSuppressed: true, verify: true, verifyOnlyFingerprint: args.fingerprint, noColor: true });
325
+ const rotation = renderRotation(result.findings, loadAcks(), loadDismissed());
326
+ const after = rotation.entries.find((e) => e.fingerprint === args.fingerprint);
327
+ const checkedAt = new Date().toISOString();
328
+ if (!after || after.verified == null) {
329
+ return textResult({
330
+ fingerprint: args.fingerprint, found: true, ruleId: before.ruleId, verifiable: true, verified: "unknown",
331
+ checkedAt, summary: "The vendor check could not be completed (network error, timeout, or unexpected response). This does not mean the credential is inactive -- treat it as unverified, not as safe.",
332
+ });
333
+ }
334
+ const verified = after.verified === "active" ? "active" : after.verified === "invalid" ? "invalid" : "unknown";
335
+ const summary = verified === "active"
336
+ ? `ACTIVE: this is a real, working credential. Rotate it. Checked ${checkedAt}.`
337
+ : verified === "invalid"
338
+ ? `Inactive: the vendor rejected it. Checked ${checkedAt}.`
339
+ : `Could not verify${after.verifiedDetail ? `: ${after.verifiedDetail}` : ""}. Treat as unverified, not as safe. Checked ${checkedAt}.`;
340
+ return textResult({
341
+ fingerprint: args.fingerprint, found: true, ruleId: before.ruleId, verifiable: true, verified,
342
+ verifiedDetail: after.verifiedDetail || null, checkedAt, summary,
343
+ });
344
+ }
345
+
286
346
  const tools = new Map();
287
347
  tools.set("residoo_scan", {
288
348
  name: "residoo_scan",
@@ -391,6 +451,39 @@ function buildTools({ sources }) {
391
451
  });
392
452
  }
393
453
 
454
+ // Genuinely different from residoo_scan/residoo_check even though it also
455
+ // only reads local disk first: its SECOND step makes a real outbound
456
+ // network request to the credential's own vendor, using the actual secret
457
+ // value, to ask whether it still works. Nothing else in this file ever
458
+ // leaves the machine. Dynamically OMITTED from this Map (same pattern as
459
+ // residoo_run_with_cred above) unless RESIDOO_MCP_ALLOW_VERIFY is set to
460
+ // "1" or "true" -- an operator must deliberately opt in, outside the
461
+ // conversation, before this tool exists at all, so a default `residoo mcp`
462
+ // install stays true to "zero network calls" without qualification. This
463
+ // is a STRICTER gate than residoo_scan/residoo_check need, because giving
464
+ // this its own clearly-named, clearly-described tool (rather than a
465
+ // boolean flag buried on residoo_scan) only solves the discovery/approval-
466
+ // prompt-legibility problem the original MCP design flagged -- it does not
467
+ // by itself decide whether an autonomous model should ever be allowed to
468
+ // trigger a real vendor API call with a real secret. That is the
469
+ // operator's call, made once, outside any conversation.
470
+ const mcpAllowVerify = process.env.RESIDOO_MCP_ALLOW_VERIFY === "1" || process.env.RESIDOO_MCP_ALLOW_VERIFY === "true";
471
+ if (mcpAllowVerify) {
472
+ tools.set("residoo_verify_finding", {
473
+ name: "residoo_verify_finding",
474
+ description: "Ask ONE credential's own vendor, live, whether it is still active -- unlike every other residoo tool, this makes a real outbound network request (e.g. to Slack's auth.test, GitHub's user endpoint) using the actual secret value found on disk. The raw value itself is still never returned to you, only the vendor's answer: active (a real, working credential -- rotate it), invalid (the vendor already rejected it), or unknown (the check failed or timed out -- treat this the same as active, not as reassurance). fingerprint MUST be copied verbatim from a fingerprint field returned by a prior residoo_scan or residoo_check call in this conversation -- never construct or guess one. Only single-token credential types are supported (Slack, GitHub, OpenAI, Anthropic, Stripe, and similar) -- paired credentials (AWS access key + secret, PlanetScale, MongoDB Atlas) return verifiable:false; use `residoo scan --verify` from a terminal for those. This tool only exists because an operator deliberately enabled it outside this conversation (RESIDOO_MCP_ALLOW_VERIFY) -- never ask a human to paste a raw credential value to use it; it already reads the value residoo found on disk.",
475
+ inputSchema: {
476
+ type: "object",
477
+ properties: {
478
+ fingerprint: { type: "string", pattern: "^rf1-[0-9a-f]{32}$", description: "Exact fingerprint string from a prior scan/check finding. Never invent one." },
479
+ },
480
+ required: ["fingerprint"],
481
+ additionalProperties: false,
482
+ },
483
+ handler: handleVerifyFinding,
484
+ });
485
+ }
486
+
394
487
  return tools;
395
488
  }
396
489
 
package/src/scan.js CHANGED
@@ -18,6 +18,7 @@ const {
18
18
  verifyFlyioBearerToken, verifyMongoDbAtlasCredential, verifyNeonKey, verifyPostHogKey,
19
19
  } = require("./verify");
20
20
  const { c, makePaint } = require("./color");
21
+ const { fingerprintFinding } = require("./rotation");
21
22
 
22
23
  // PlanetScale's id half: 12 lowercase alphanumeric characters, no prefix —
23
24
  // confirmed via planetscale.com/docs/api/reference/service-tokens. Searched
@@ -265,7 +266,7 @@ function localTimestamp(d) {
265
266
  * absolute path can itself carry a username or a project name the rest of
266
267
  * this report is careful never to print.
267
268
  */
268
- async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, onBeforeVerify = null, noColor = false } = {}) {
269
+ async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, verifyOnlyFingerprint = null, onBeforeVerify = null, noColor = false } = {}) {
269
270
  const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
270
271
  // The decode pass (see decode.js) only applies high-confidence, vendor-
271
272
  // prefixed rules to decoded bytes: random binary that decodes to printable
@@ -481,7 +482,18 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
481
482
  // re-echoed across several lines gets several finding objects, and
482
483
  // the eventual result is applied to every one of them, not only
483
484
  // the first.
484
- if (verify && secretFinding && rawPairedSecret) {
485
+ //
486
+ // verifyOnlyFingerprint (residoo_verify_finding, src/mcpTools.js):
487
+ // when set, this scan still WALKS every file as normal, but only
488
+ // the one finding whose fingerprint matches is ever queued for a
489
+ // real network call -- every other eligible credential on the
490
+ // machine is silently skipped, matching that MCP tool's own
491
+ // documented "one credential per call" promise exactly. Computed
492
+ // from primaryFinding, not secretFinding/idFinding, because the
493
+ // fingerprint a caller holds always names the record they saw in
494
+ // a prior scan/check result, which is always the primary one.
495
+ const matchesTarget = !verifyOnlyFingerprint || fingerprintFinding(primaryFinding) === verifyOnlyFingerprint;
496
+ if (verify && matchesTarget && secretFinding && rawPairedSecret) {
485
497
  if (!pendingAwsVerifications.has(m[0]) && pendingAwsVerifications.size < MAX_VERIFICATIONS_PER_VENDOR) {
486
498
  pendingAwsVerifications.set(m[0], { secretValue: rawPairedSecret, refs: [] });
487
499
  }
@@ -490,7 +502,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
490
502
  }
491
503
  // --verify, PlanetScale: same dedup-by-anchor-value shape as AWS
492
504
  // above, keyed by the secret (the confirmed anchor) this time.
493
- if (verify && planetScaleIdFinding && rawPlanetScaleId) {
505
+ if (verify && matchesTarget && planetScaleIdFinding && rawPlanetScaleId) {
494
506
  if (!pendingPlanetScaleVerifications.has(m[0]) && pendingPlanetScaleVerifications.size < MAX_VERIFICATIONS_PER_VENDOR) {
495
507
  pendingPlanetScaleVerifications.set(m[0], { idValue: rawPlanetScaleId, refs: [] });
496
508
  }
@@ -499,7 +511,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
499
511
  }
500
512
  // --verify, MongoDB Atlas: same dedup-by-anchor-value shape as
501
513
  // AWS/PlanetScale above, keyed by the secret this time.
502
- if (verify && mongoDbIdFinding && rawMongoDbId) {
514
+ if (verify && matchesTarget && mongoDbIdFinding && rawMongoDbId) {
503
515
  if (!pendingMongoDbAtlasVerifications.has(m[0]) && pendingMongoDbAtlasVerifications.size < MAX_VERIFICATIONS_PER_VENDOR) {
504
516
  pendingMongoDbAtlasVerifications.set(m[0], { idValue: rawMongoDbId, refs: [] });
505
517
  }
@@ -512,7 +524,7 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
512
524
  // dedup-by-value / accumulate-all-refs shape as the AWS map above,
513
525
  // just one level deeper (keyed by rule id too, since several
514
526
  // vendors share this path).
515
- if (verify && !suppressedReason && SIMPLE_VERIFY_FNS[rule.id]) {
527
+ if (verify && matchesTarget && !suppressedReason && SIMPLE_VERIFY_FNS[rule.id]) {
516
528
  let byValue = pendingSimpleVerifications.get(rule.id);
517
529
  if (!byValue) {
518
530
  byValue = new Map();
@@ -879,4 +891,13 @@ function emptyResult() {
879
891
  // VENDOR_EXAMPLE_VALUES is exported for the smoke tests, which assert every
880
892
  // literal in it is still matched IN FULL by some detection rule — a literal
881
893
  // no rule can produce as a whole match is dead weight that suppresses nothing.
882
- module.exports = { scan, emptyResult, VENDOR_EXAMPLE_VALUES };
894
+ // Rule ids `scan({verify: true})` knows how to check live, for callers (the
895
+ // residoo_verify_finding MCP tool) that need to tell a caller upfront
896
+ // whether a given finding's ruleId is even eligible, without attempting a
897
+ // scan first. AWS/PlanetScale/MongoDB Atlas pairs are deliberately excluded
898
+ // here even though `scan()` itself does verify them: each needs BOTH halves
899
+ // of a pair in hand at once, which a single fingerprint alone can't express,
900
+ // so residoo_verify_finding's v1 only supports the single-token vendors below.
901
+ const VERIFIABLE_RULE_IDS = new Set(Object.keys(SIMPLE_VERIFY_FNS));
902
+
903
+ module.exports = { scan, emptyResult, VENDOR_EXAMPLE_VALUES, VERIFIABLE_RULE_IDS };