residoo 0.8.0 → 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.
Files changed (3) hide show
  1. package/README.md +54 -530
  2. package/package.json +1 -1
  3. package/src/guard.js +43 -9
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.8.0:
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.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).
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,267 +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 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
202
  ## Sources supported today
620
203
 
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.
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).
684
208
 
685
209
  ## License
686
210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.8.0",
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/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