residoo 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,6 +40,19 @@ Values are redacted in this report (first/last 4 characters only). Nothing
40
40
  scanned here left your machine; residoo makes no network calls.
41
41
  ```
42
42
 
43
+ That's one snapshot. `residoo watch` runs the same engine continuously and
44
+ alerts the moment a new secret lands, instead of waiting for you to
45
+ remember to scan again; no other tool in the field has anything like it
46
+ (see [Watch: continuous scanning](#watch-continuous-scanning)):
47
+
48
+ ```
49
+ $ residoo watch
50
+ watching 43 sources, 118 files · polling every 5s
51
+
52
+ 2026-09-03 14:02:11 [high] AWS Access Key ID AKIA****ABCD
53
+ claude-code · session-9f2c.jsonl:214 · rf1-8a3e91 Rotate: https://.../access_keys
54
+ ```
55
+
43
56
  > [!NOTE]
44
57
  > gitleaks and trufflehog scan **commits**. residoo scans the **conversation
45
58
  > transcripts** an AI agent leaves behind: a different, previously
@@ -116,6 +129,10 @@ reproduction; everything needed to rerun it ships in this repo.
116
129
  - **`residoo watch`**: continuous scanning instead of one snapshot, alerting
117
130
  the moment a new secret lands in a transcript. See
118
131
  [Watch: continuous scanning](#watch-continuous-scanning) below.
132
+ - **`residoo mcp`**: query findings and manage rotation from inside Claude
133
+ Code itself, over a hand-rolled MCP server. See
134
+ [MCP: query findings from inside Claude Code](#mcp-query-findings-from-inside-claude-code)
135
+ below.
119
136
 
120
137
  ## Beyond transcripts: configs and planted persistence
121
138
 
@@ -288,7 +305,7 @@ As a GitHub Action (this repo doubles as a composite action):
288
305
  ```yaml
289
306
  steps:
290
307
  - uses: actions/checkout@v4
291
- - uses: dandovdub/residoo@v0.5.0
308
+ - uses: dandovdub/residoo@v0.6.0
292
309
  ```
293
310
 
294
311
  As a pre-commit hook:
@@ -296,7 +313,7 @@ As a pre-commit hook:
296
313
  ```yaml
297
314
  repos:
298
315
  - repo: https://github.com/dandovdub/residoo
299
- rev: v0.5.0
316
+ rev: v0.6.0
300
317
  hooks:
301
318
  - id: residoo
302
319
  ```
@@ -419,6 +436,37 @@ verified directly against each one's own `--help` output rather than
419
436
  assumed; see [docs/comparison.md](docs/comparison.md) for how the one
420
437
  adjacent thing, GitGuardian's `ggshield` AI hook, works differently.
421
438
 
439
+ ## MCP: query findings from inside Claude Code
440
+
441
+ `residoo mcp` runs residoo as an MCP server over stdio, so Claude Code can
442
+ query findings and manage the rotation ledger conversationally instead of
443
+ you running the CLI in a terminal:
444
+
445
+ ```bash
446
+ claude mcp add residoo -- residoo mcp
447
+ ```
448
+
449
+ or add it directly to `.mcp.json`:
450
+
451
+ ```json
452
+ {
453
+ "mcpServers": {
454
+ "residoo": { "type": "stdio", "command": "residoo", "args": ["mcp"] }
455
+ }
456
+ }
457
+ ```
458
+
459
+ Five tools, mirroring the CLI exactly: `residoo_scan` (a fresh scan,
460
+ merged with rotation status), `residoo_check` (only what's new since the
461
+ last check in this conversation, backed by the same engine as `watch`),
462
+ `residoo_explain` (a rule's rotation runbook), and `residoo_ack` /
463
+ `residoo_dismiss` (append to the local ledger). Every value returned is
464
+ redacted the same way the CLI's own output is; nothing here makes a
465
+ network call or touches the transcript files themselves. Like the rest of
466
+ residoo, this is hand-rolled against the MCP spec directly, not built on
467
+ `@modelcontextprotocol/sdk`: zero runtime dependencies stays true here
468
+ too.
469
+
422
470
  ## Sources supported today
423
471
 
424
472
  43 sources: 42 transcript stores plus the agent-config source above, in two
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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
@@ -11,6 +11,8 @@ const {
11
11
  ROTATION_GUIDANCE, guidanceFor, loadAcks, loadDismissed, ackFinding, dismissFinding, renderRotation,
12
12
  } = require("./rotation");
13
13
  const { startWatch, isTailable } = require("./watch");
14
+ const { startMcpServer } = require("./mcp");
15
+ const { buildTools } = require("./mcpTools");
14
16
 
15
17
  /**
16
18
  * A source is unavailable for the ordinary reason (not installed — nothing
@@ -147,6 +149,20 @@ Watch:
147
149
  Ctrl+C stops cleanly and prints a session summary (skipped with --json,
148
150
  where the same information is one final NDJSON event).
149
151
 
152
+ MCP:
153
+ residoo mcp run residoo as an MCP server over stdio, so
154
+ Claude Code (or any other MCP client) can query
155
+ findings and manage rotation conversationally
156
+ instead of a human running the CLI. No network
157
+ calls, nothing destructive: the 5 exposed tools
158
+ (residoo_scan, residoo_check, residoo_explain,
159
+ residoo_ack, residoo_dismiss) mirror scan/watch/
160
+ explain/ack/dismiss exactly, and every value
161
+ returned is redacted the same way. Register it
162
+ with "claude mcp add residoo -- residoo mcp".
163
+ Zero runtime dependencies: the protocol is hand-
164
+ rolled, not the official SDK.
165
+
150
166
  Rotation:
151
167
  residoo explain <rule-id> full rotation runbook for one detection rule
152
168
  (where to revoke, steps, what revocation does)
@@ -571,6 +587,44 @@ async function runWatch(args) {
571
587
  return 0;
572
588
  }
573
589
 
590
+ /**
591
+ * `residoo mcp`: run residoo as an MCP server over stdio. See src/mcp.js
592
+ * for the protocol engine and src/mcpTools.js for the tool catalog; this
593
+ * function is only the startup banner (stderr only -- see mcp.js's own
594
+ * doc comment on why stdout must never carry anything but protocol
595
+ * messages) and wiring SIGINT/SIGTERM to a clean stop, mirroring runWatch.
596
+ */
597
+ async function runMcp(args) {
598
+ const { version } = require("../package.json");
599
+ const sources = availableSources();
600
+ process.stderr.write(
601
+ sources.length
602
+ ? `residoo mcp: ${sources.length} source(s) available on this machine: ${sources.map((s) => s.label()).join(", ")}\n`
603
+ : `residoo mcp: no known transcript sources found on this machine (residoo_scan will report none until one is installed and residoo mcp is restarted).\n`
604
+ );
605
+ process.stderr.write("residoo mcp: ready. Waiting for a client on stdin...\n");
606
+
607
+ const { promise, stop } = startMcpServer({
608
+ tools: buildTools({ sources }),
609
+ serverInfo: { name: "residoo", version },
610
+ instructions: "Find secrets leaking through this machine's AI coding agent session histories. All tool output is redacted; raw secret values are never returned. Nothing here is destructive: scanning is read-only, and ack/dismiss only append to a local audit ledger.",
611
+ });
612
+
613
+ let signalled = false;
614
+ const onSignal = () => {
615
+ if (signalled) return;
616
+ signalled = true;
617
+ stop();
618
+ };
619
+ process.once("SIGINT", onSignal);
620
+ process.once("SIGTERM", onSignal);
621
+
622
+ await promise;
623
+ process.removeListener("SIGINT", onSignal);
624
+ process.removeListener("SIGTERM", onSignal);
625
+ return 0;
626
+ }
627
+
574
628
  async function main(argv) {
575
629
  const args = argv.slice(2);
576
630
  if (args.includes("-h") || args.includes("--help") || args.length === 0) {
@@ -584,6 +638,7 @@ async function main(argv) {
584
638
  if (cmd === "ack") return runAck(args);
585
639
  if (cmd === "dismiss") return runDismiss(args);
586
640
  if (cmd === "watch") return runWatch(args);
641
+ if (cmd === "mcp") return runMcp(args);
587
642
  if (cmd !== "scan") {
588
643
  process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
589
644
  return 2;
package/src/mcp.js ADDED
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+
3
+ const { createInterface } = require("readline/promises");
4
+
5
+ /**
6
+ * `residoo mcp`: a hand-rolled MCP (Model Context Protocol) server over
7
+ * stdio, so Claude Code (or any other MCP client) can query residoo's
8
+ * findings and rotation ledger directly instead of a human running the
9
+ * CLI in a terminal. No `@modelcontextprotocol/sdk` dependency -- residoo
10
+ * has zero runtime dependencies, no exceptions (see CONTRIBUTING.md), and
11
+ * MCP's stdio transport is newline-delimited JSON-RPC 2.0, which Node's
12
+ * own `readline` already handles; every `.jsonl` source adapter in
13
+ * src/sources/ hand-rolls the same kind of line-delimited JSON parsing.
14
+ *
15
+ * This file is the protocol engine only: framing, dispatch, version
16
+ * negotiation, the outgoing writer, shutdown. It has zero domain
17
+ * knowledge of scanning/rotation -- see src/mcpTools.js for the actual
18
+ * tool catalog. `tools` here is just a `Map<name, {name, description,
19
+ * inputSchema, handler}>` handed in by the caller (mirrors `startWatch`'s
20
+ * `sources` being handed in by its caller rather than looked up itself).
21
+ *
22
+ * Implements the Legacy (initialize-handshake) MCP era only
23
+ * (`2025-11-25`/`2025-06-18`/`2025-03-26`), which is Claude Code's own
24
+ * documented DEFAULT for every stdio server -- it only probes stdio
25
+ * servers for the newer, per-request "Modern" era
26
+ * (`server/discover`-based) when a user explicitly sets
27
+ * `MCP_PROTOCOL_NEGOTIATION=auto`. In that opt-in case, the generic
28
+ * "unknown method" handler below answers `server/discover` instantly with
29
+ * a plain JSON-RPC -32601, which is exactly what the MCP spec defines as
30
+ * triggering a Dual-era client's fallback to the Legacy handshake -- so
31
+ * this server works either way, and implementing `server/discover` itself
32
+ * is a deliberate, non-blocking v1 scope decision, not an oversight.
33
+ *
34
+ * Per the spec's own words: "The server MUST NOT write anything to its
35
+ * stdout that is not a valid MCP message." `send()` below is the ONLY
36
+ * function in this file (or in mcpTools.js) allowed to touch `output`;
37
+ * every log line, startup banner, and shutdown summary goes to
38
+ * `errOutput` instead. This is the same "stdout is sacred" discipline
39
+ * watch.js already established, enforced even more strictly here, since
40
+ * a single stray byte on stdout doesn't just create noise -- it can
41
+ * corrupt the client's JSON-RPC stream.
42
+ */
43
+
44
+ const JSONRPC_VERSION = "2.0";
45
+
46
+ // All three are "Legacy"-era (initialize-handshake) protocol revisions;
47
+ // none of the wire shapes this file implements (initialize result,
48
+ // tools/list, tools/call, isError) differ across them, so supporting all
49
+ // three costs nothing beyond this list. If the client's requested version
50
+ // isn't one of these, DEFAULT_PROTOCOL_VERSION is what we claim instead.
51
+ const SUPPORTED_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"];
52
+ const DEFAULT_PROTOCOL_VERSION = "2025-06-18";
53
+
54
+ function negotiateProtocolVersion(requested) {
55
+ if (typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) return requested;
56
+ return DEFAULT_PROTOCOL_VERSION;
57
+ }
58
+
59
+ /**
60
+ * `startMcpServer({tools, serverInfo, instructions, input, output,
61
+ * errOutput})` -> `{promise, stop}`. All I/O injectable (mirrors
62
+ * `startWatch`'s `{sources, options, out, errOut}` contract) so this can
63
+ * be driven by tests with fake streams and a stub tool map, with no real
64
+ * scanning involved.
65
+ */
66
+ function startMcpServer({
67
+ tools,
68
+ serverInfo,
69
+ instructions,
70
+ input = process.stdin,
71
+ output = process.stdout,
72
+ errOutput = process.stderr,
73
+ } = {}) {
74
+ const rl = createInterface({ input, crlfDelay: Infinity });
75
+ let stopped = false;
76
+ let requestsHandled = 0;
77
+
78
+ /**
79
+ * The single write path. `JSON.stringify` already escapes any raw `\n`
80
+ * inside a string VALUE as the two-character sequence `\` `n` (JSON's
81
+ * own grammar, RFC 8259, forbids a literal control character in a
82
+ * string) -- no per-field newline scrubbing is needed here. The only
83
+ * raw 0x0A byte in the whole write is the one appended below, as the
84
+ * stdio transport's own frame delimiter.
85
+ */
86
+ function send(message) {
87
+ let text;
88
+ try {
89
+ text = JSON.stringify(message);
90
+ } catch (err) {
91
+ // `message` contained something JSON.stringify can't serialize (a
92
+ // circular reference, a BigInt) -- a bug in a tool handler, not a
93
+ // reason to crash the connection or silently drop a reply the
94
+ // client may be blocked waiting on. `message.id`, when present,
95
+ // always came from JSON.parse on the client's own request and
96
+ // already passed the id-type check in handleLine, so it's
97
+ // independently safe to reserialize on its own here.
98
+ errOutput.write(`residoo mcp: failed to serialize outgoing message: ${err instanceof Error ? err.message : String(err)}\n`);
99
+ const fallbackId = message && typeof message === "object" && "id" in message ? message.id : null;
100
+ text = JSON.stringify({
101
+ jsonrpc: JSONRPC_VERSION, id: fallbackId,
102
+ error: { code: -32603, message: "Internal error: response could not be serialized" },
103
+ });
104
+ }
105
+ output.write(text + "\n");
106
+ }
107
+
108
+ function sendError(id, code, message, data) {
109
+ send({ jsonrpc: JSONRPC_VERSION, id, error: data === undefined ? { code, message } : { code, message, data } });
110
+ }
111
+
112
+ function sendResult(id, result) {
113
+ send({ jsonrpc: JSONRPC_VERSION, id, result });
114
+ }
115
+
116
+ function handleInitialize(params, id) {
117
+ const protocolVersion = negotiateProtocolVersion(params && params.protocolVersion);
118
+ sendResult(id, {
119
+ protocolVersion,
120
+ capabilities: { tools: {} },
121
+ serverInfo,
122
+ ...(instructions ? { instructions } : {}),
123
+ });
124
+ }
125
+
126
+ function handleToolsList(id) {
127
+ const list = [];
128
+ for (const tool of tools.values()) {
129
+ list.push({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema });
130
+ }
131
+ sendResult(id, { tools: list });
132
+ }
133
+
134
+ async function handleToolsCall(params, id) {
135
+ if (!params || typeof params !== "object" || typeof params.name !== "string") {
136
+ sendError(id, -32602, "Invalid params: 'name' (string) is required");
137
+ return;
138
+ }
139
+ const tool = tools.get(params.name);
140
+ if (!tool) {
141
+ sendError(id, -32602, `Unknown tool: ${params.name}`);
142
+ return;
143
+ }
144
+
145
+ const args = params.arguments && typeof params.arguments === "object" && !Array.isArray(params.arguments)
146
+ ? params.arguments : {};
147
+ let result;
148
+ try {
149
+ // The handler owns its OWN input validation and is expected to
150
+ // return {content, isError:true} for both a bad-argument case and a
151
+ // genuine execution failure -- see SEP-1303: input validation
152
+ // errors are Tool Execution Errors, not Protocol Errors, so the
153
+ // model can see the message and self-correct in the same turn.
154
+ // This dispatcher does not and should not try to tell those two
155
+ // cases apart.
156
+ result = await tool.handler(args);
157
+ if (!result || !Array.isArray(result.content)) {
158
+ // Defensive: a handler bug in mcpTools.js must not corrupt the
159
+ // wire protocol -- fail safe into a well-formed isError result.
160
+ result = { content: [{ type: "text", text: "Tool returned no content (internal error)." }], isError: true };
161
+ }
162
+ } catch (err) {
163
+ // Anything a handler THROWS still lands as a tool execution error,
164
+ // not -32603 -- that code is reserved for bugs in this dispatcher
165
+ // itself, not in a tool. errOutput gets the full detail for
166
+ // operator debugging; the client only ever sees the message string.
167
+ errOutput.write(`residoo mcp: tool "${params.name}" threw: ${err instanceof Error ? (err.stack || err.message) : String(err)}\n`);
168
+ result = { content: [{ type: "text", text: `Failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
169
+ }
170
+ sendResult(id, result);
171
+ }
172
+
173
+ async function dispatch(method, params, hasId, id) {
174
+ switch (method) {
175
+ case "initialize":
176
+ if (!hasId) return;
177
+ return handleInitialize(params, id);
178
+ case "notifications/initialized":
179
+ // A true notification by the method's own contract: never reply,
180
+ // even if a client mistakenly attached an id -- nothing in v1
181
+ // depends on this flag anyway (no server-initiated requests).
182
+ return;
183
+ case "tools/list":
184
+ if (!hasId) return;
185
+ return handleToolsList(id);
186
+ case "tools/call":
187
+ if (!hasId) return;
188
+ return handleToolsCall(params, id);
189
+ default:
190
+ // Includes a `server/discover` probe from a client with
191
+ // MCP_PROTOCOL_NEGOTIATION=auto (see file doc comment) -- MUST
192
+ // reply fast (no I/O, no await, above) so that fallback resolves
193
+ // immediately rather than after a client-side timeout.
194
+ if (!hasId) return;
195
+ sendError(id, -32601, `Method not found: ${method}`);
196
+ }
197
+ }
198
+
199
+ async function handleLine(line) {
200
+ if (line.trim() === "") return false;
201
+ let msg;
202
+ try {
203
+ msg = JSON.parse(line);
204
+ } catch {
205
+ // Per JSON-RPC 2.0 section 5: if the id could not even be
206
+ // determined (a parse failure means we never got that far), the
207
+ // error response's id MUST be null.
208
+ sendError(null, -32700, "Parse error");
209
+ errOutput.write(`residoo mcp: received unparseable line (${line.length} bytes): ${line.slice(0, 200)}\n`);
210
+ return false;
211
+ }
212
+ if (msg === null || typeof msg !== "object" || Array.isArray(msg)) {
213
+ sendError(null, -32600, "Invalid Request: expected a JSON object");
214
+ return false;
215
+ }
216
+
217
+ // Deliberately `hasOwnProperty`, never `msg.id` truthiness -- id:0 is
218
+ // a valid, falsy request id, and conflating "id key absent" (a
219
+ // notification: never reply, even with an error) with "id present but
220
+ // falsy" is exactly the kind of one-character bug that silently
221
+ // breaks a client's parser.
222
+ const hasId = Object.prototype.hasOwnProperty.call(msg, "id");
223
+ const id = hasId ? msg.id : undefined;
224
+ const idIsValidType = id === null || typeof id === "string" || typeof id === "number";
225
+
226
+ if (msg.jsonrpc !== JSONRPC_VERSION || typeof msg.method !== "string") {
227
+ if (!hasId) return false; // malformed NOTIFICATION: JSON-RPC promises no reply, ever
228
+ sendError(idIsValidType ? id : null, -32600, "Invalid Request");
229
+ return false;
230
+ }
231
+ if (hasId && !idIsValidType) {
232
+ // id present but not string/number/null: can't trust echoing it
233
+ // back (and can't safely re-serialize it if it's e.g. an object).
234
+ sendError(null, -32600, "Invalid Request: id must be a string, number, or null");
235
+ return false;
236
+ }
237
+
238
+ try {
239
+ await dispatch(msg.method, msg.params, hasId, id);
240
+ } catch (err) {
241
+ errOutput.write(`residoo mcp: internal error handling "${msg.method}": ${err instanceof Error ? (err.stack || err.message) : String(err)}\n`);
242
+ if (hasId) sendError(id, -32603, "Internal error");
243
+ }
244
+ return hasId;
245
+ }
246
+
247
+ function stop() {
248
+ if (stopped) return;
249
+ stopped = true;
250
+ rl.close(); // unblocks the `for await` loop below on its next iteration
251
+ }
252
+
253
+ const promise = (async () => {
254
+ for await (const line of rl) {
255
+ if (stopped) break;
256
+ if (await handleLine(line)) requestsHandled++;
257
+ }
258
+ if (!stopped) stopped = true;
259
+ errOutput.write(`residoo mcp: shutting down. Handled ${requestsHandled} request(s).\n`);
260
+ return { requestsHandled };
261
+ })();
262
+
263
+ return { promise, stop };
264
+ }
265
+
266
+ module.exports = { startMcpServer, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_PROTOCOL_VERSION };
@@ -0,0 +1,341 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const { scan } = require("./scan");
5
+ const {
6
+ ROTATION_GUIDANCE, guidanceFor, loadAcks, loadDismissed,
7
+ ackFinding, dismissFinding, renderRotation,
8
+ } = require("./rotation");
9
+ const { sweepOnce } = require("./watch");
10
+
11
+ /**
12
+ * The tool catalog for `residoo mcp` (see src/mcp.js for the protocol
13
+ * engine that calls into this). Every handler here calls the same PURE
14
+ * engine functions the CLI itself uses (`scan`, `renderRotation`,
15
+ * `ackFinding`/`dismissFinding`, `guidanceFor`, `sweepOnce`) -- never
16
+ * `cli.js`'s `runX()` functions or `watch.js`'s `startWatch()`, since
17
+ * those specific functions write to stdout by design (they're the
18
+ * human-facing presenters), and this file's whole job is to never let a
19
+ * byte reach stdout except through mcp.js's own `send()`.
20
+ *
21
+ * `verify` is not exposed as a parameter on ANY tool here, on purpose: a
22
+ * human typing `--verify` at a terminal is a deliberate, legible act; an
23
+ * autonomous model choosing a network-triggering parameter mid-
24
+ * conversation is a different trust boundary, and a generic tool-approval
25
+ * prompt may not surface that a given call also makes a live vendor API
26
+ * request with a real secret. Every `scan()`/`sweepOnce()` call below
27
+ * hardcodes `verify: false`.
28
+ */
29
+
30
+ const FINGERPRINT_PATTERN = /^rf1-[0-9a-f]{32}$/;
31
+
32
+ function textResult(obj) {
33
+ return { content: [{ type: "text", text: JSON.stringify(obj) }] };
34
+ }
35
+ function errorResult(message) {
36
+ return { content: [{ type: "text", text: message }], isError: true };
37
+ }
38
+
39
+ function rejectUnknownKeys(args, allowed) {
40
+ const errs = [];
41
+ for (const k of Object.keys(args)) {
42
+ if (!allowed.has(k)) errs.push(`unexpected property "${k}"`);
43
+ }
44
+ return errs;
45
+ }
46
+
47
+ /** Shared arg shape for residoo_scan/residoo_check: includeNoisy, includeSuppressed, maxEntries. */
48
+ function validateSweepArgs(args, allowedKeys) {
49
+ const errs = rejectUnknownKeys(args, allowedKeys);
50
+ if (args.includeNoisy !== undefined && typeof args.includeNoisy !== "boolean") errs.push("includeNoisy must be a boolean");
51
+ if (args.includeSuppressed !== undefined && typeof args.includeSuppressed !== "boolean") errs.push("includeSuppressed must be a boolean");
52
+ let maxEntries = 25;
53
+ if (args.maxEntries !== undefined) {
54
+ if (typeof args.maxEntries !== "number" || !Number.isInteger(args.maxEntries) || args.maxEntries < 1 || args.maxEntries > 200) {
55
+ errs.push("maxEntries must be an integer between 1 and 200");
56
+ } else {
57
+ maxEntries = args.maxEntries;
58
+ }
59
+ }
60
+ return { errs, includeNoisy: args.includeNoisy === true, includeSuppressed: args.includeSuppressed === true, maxEntries };
61
+ }
62
+
63
+ /** Drop the full step-by-step runbook (redundant once per shared rule id across many entries -- call residoo_explain for that) and any null-valued optional field. */
64
+ function trimGuidance(g) {
65
+ if (!g) return null;
66
+ const out = { label: g.label };
67
+ if (g.rotateUrl) out.rotateUrl = g.rotateUrl;
68
+ if (g.consolePath) out.consolePath = g.consolePath;
69
+ if (g.revokeNote) out.revokeNote = g.revokeNote;
70
+ if (g.generic) out.generic = true;
71
+ return out;
72
+ }
73
+
74
+ function shapeRotationEntry(e) {
75
+ const out = {
76
+ fingerprint: e.fingerprint, ruleId: e.ruleId, label: e.label, preview: e.preview,
77
+ status: e.status, occurrences: e.occurrences, files: e.files, sources: e.sources,
78
+ guidance: trimGuidance(e.guidance),
79
+ };
80
+ if (e.ackedAt) out.ackedAt = e.ackedAt;
81
+ if (e.ackNote) out.ackNote = e.ackNote;
82
+ if (e.lastSeenMs != null) out.lastSeenAt = new Date(e.lastSeenMs).toISOString();
83
+ if (e.pairedSecretPreview) out.pairedSecretPreview = e.pairedSecretPreview;
84
+ if (e.pairedAccessKeyPreview) out.pairedAccessKeyPreview = e.pairedAccessKeyPreview;
85
+ if (e.pairedOtherPreview) { out.pairedOtherPreview = e.pairedOtherPreview; out.pairedOtherLabel = e.pairedOtherLabel; }
86
+ if (e.jwtExpiresAtMs != null) out.jwtExpiresAtMs = e.jwtExpiresAtMs;
87
+ return out;
88
+ }
89
+
90
+ function buildScanSummary(scope, counts, filesScanned, sourceCount) {
91
+ const scopeText = scope.type === "project" ? `project "${scope.projectDir}"` : `${sourceCount} source(s)`;
92
+ if (counts.distinct === 0) return `Scanned ${scopeText}, ${filesScanned} file(s). No secrets found.`;
93
+ const bits = [];
94
+ if (counts.pending > 0) bits.push(`${counts.pending} pending rotation`);
95
+ if (counts.acked > 0) bits.push(`${counts.acked} already acknowledged`);
96
+ if (counts.dismissed > 0) bits.push(`${counts.dismissed} dismissed`);
97
+ return `Scanned ${scopeText}, ${filesScanned} file(s). ${counts.distinct} distinct secret(s): ${bits.join(", ")}.`;
98
+ }
99
+
100
+ /**
101
+ * `buildTools({sources})` returns a fresh `Map<name, {name, description,
102
+ * inputSchema, handler}>` for one `residoo mcp` server invocation.
103
+ * Session-scoped state (the fingerprint-hallucination guard, and
104
+ * residoo_check's tracked/seen Maps) lives in this function's closure, not
105
+ * at module scope, so each server run gets its own clean state and tests
106
+ * can build independent tool sets without cross-test pollution.
107
+ *
108
+ * `sources` is captured ONCE here (evaluated by the caller before this is
109
+ * called), matching `residoo watch`'s own existing, documented limitation:
110
+ * an agent tool installed mid-session needs a restart to be picked up.
111
+ */
112
+ function buildTools({ sources }) {
113
+ const sessionSeenFingerprints = new Set();
114
+ const checkTracked = new Map();
115
+ const checkSeen = new Map();
116
+ let checkStarted = false;
117
+
118
+ async function handleScan(args) {
119
+ const SCAN_KEYS = new Set(["projectDir", "includeNoisy", "includeSuppressed", "maxEntries"]);
120
+ const { errs, includeNoisy, includeSuppressed, maxEntries } = validateSweepArgs(args, SCAN_KEYS);
121
+ if (args.projectDir !== undefined && typeof args.projectDir !== "string") errs.push("projectDir must be a string");
122
+ if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
123
+
124
+ let scanSources;
125
+ let scope = { type: "machine" };
126
+ if (args.projectDir !== undefined) {
127
+ const projectArtifacts = require("./sources/project-artifacts");
128
+ const resolved = path.resolve(args.projectDir);
129
+ const src = projectArtifacts.withRoot(resolved);
130
+ if (!src.available()) return errorResult(`"${args.projectDir}" is not a readable directory.`);
131
+ scanSources = [src];
132
+ scope = { type: "project", projectDir: resolved };
133
+ } else {
134
+ scanSources = sources;
135
+ }
136
+
137
+ const result = await scan({ sources: scanSources, includeNoisy, includeSuppressed, verify: false, noColor: true });
138
+ const acks = loadAcks();
139
+ const dismissed = loadDismissed();
140
+ const rotation = renderRotation(result.findings, acks, dismissed);
141
+ for (const e of rotation.entries) sessionSeenFingerprints.add(e.fingerprint);
142
+
143
+ const total = rotation.entries.length;
144
+ const truncated = total > maxEntries;
145
+ const entries = rotation.entries.slice(0, maxEntries).map(shapeRotationEntry);
146
+ const sourceCount = scope.type === "project" ? 1 : scanSources.length;
147
+
148
+ return textResult({
149
+ scannedAt: new Date().toISOString(),
150
+ scope,
151
+ filesScanned: result.filesScanned,
152
+ sourcesScanned: result.sourcesScanned,
153
+ bytesScanned: result.bytesScanned,
154
+ unreadable: { count: result.unreadableFiles.length, sample: result.unreadableFiles.slice(0, 5) },
155
+ counts: rotation.counts,
156
+ entries,
157
+ truncated,
158
+ truncatedCount: truncated ? total - maxEntries : 0,
159
+ summary: buildScanSummary(scope, rotation.counts, result.filesScanned, sourceCount),
160
+ });
161
+ }
162
+
163
+ async function handleCheck(args) {
164
+ const CHECK_KEYS = new Set(["includeNoisy", "includeSuppressed", "maxEntries"]);
165
+ const { errs, includeNoisy, includeSuppressed, maxEntries } = validateSweepArgs(args, CHECK_KEYS);
166
+ if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
167
+
168
+ const firstCheckThisSession = !checkStarted;
169
+ checkStarted = true;
170
+
171
+ const ledger = { acks: loadAcks(), dismissed: loadDismissed() };
172
+ const events = [];
173
+ const emit = (e) => events.push(e);
174
+ const stats = await sweepOnce({
175
+ sources, tracked: checkTracked, seen: checkSeen, ledger,
176
+ options: { includeNoisy, includeSuppressed, verify: false, noColor: true }, emit,
177
+ });
178
+
179
+ const allNew = events.filter((e) => e.type === "finding");
180
+ const allReexposures = events.filter((e) => e.type === "reexposure");
181
+ for (const e of allNew) sessionSeenFingerprints.add(e.fingerprint);
182
+
183
+ const newFindings = allNew.slice(0, maxEntries).map((e) => ({
184
+ fingerprint: e.fingerprint, ruleId: e.ruleId, label: e.label, confidence: e.confidence,
185
+ source: e.source, relFile: e.relFile, line: e.line, lineIsAbsolute: e.lineIsAbsolute,
186
+ preview: e.preview, guidance: trimGuidance(e.guidance),
187
+ }));
188
+ const reExposures = allReexposures.slice(0, maxEntries).map((e) => ({ ruleId: e.ruleId, preview: e.preview, count: e.count }));
189
+ const droppedNew = Math.max(0, allNew.length - newFindings.length);
190
+ const droppedReexp = Math.max(0, allReexposures.length - reExposures.length);
191
+ const truncatedCount = droppedNew + droppedReexp;
192
+
193
+ let summary;
194
+ if (firstCheckThisSession && stats.loud === 0) {
195
+ summary = "First check this session -- baseline established, watching from now on. This does not mean nothing is on disk; call residoo_scan for that.";
196
+ } else if (stats.loud === 0 && stats.quiet === 0) {
197
+ summary = "Nothing new since the last check.";
198
+ } else {
199
+ const bits = [];
200
+ if (stats.loud > 0) bits.push(`${stats.loud} new finding(s)`);
201
+ if (stats.quiet > 0) bits.push(`${stats.quiet} re-exposure(s) of already-known secrets`);
202
+ summary = bits.join(", ") + " since your last check.";
203
+ }
204
+
205
+ return textResult({
206
+ checkedAt: new Date().toISOString(),
207
+ firstCheckThisSession,
208
+ newFindings,
209
+ reExposures,
210
+ counts: { newFindings: stats.loud, reExposures: stats.quiet, suppressedByLedger: stats.suppressedByLedger },
211
+ truncated: truncatedCount > 0,
212
+ truncatedCount,
213
+ summary,
214
+ });
215
+ }
216
+
217
+ async function handleExplain(args) {
218
+ const errs = rejectUnknownKeys(args, new Set(["ruleId"]));
219
+ if (args.ruleId !== undefined && typeof args.ruleId !== "string") errs.push("ruleId must be a string");
220
+ if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
221
+
222
+ if (args.ruleId === undefined) {
223
+ const ruleIds = Object.keys(ROTATION_GUIDANCE).map((id) => ({ id, label: ROTATION_GUIDANCE[id].label }));
224
+ return textResult({ ruleIds });
225
+ }
226
+ const known = Object.prototype.hasOwnProperty.call(ROTATION_GUIDANCE, args.ruleId);
227
+ const g = guidanceFor(args.ruleId);
228
+ return textResult({
229
+ ruleId: args.ruleId, known, label: g.label,
230
+ rotateUrl: g.rotateUrl || null, consolePath: g.consolePath || null,
231
+ steps: g.steps, revokeNote: g.revokeNote, generic: g.generic === true,
232
+ });
233
+ }
234
+
235
+ async function resolveTool(kind, args) {
236
+ const errs = rejectUnknownKeys(args, new Set(["fingerprint", "note"]));
237
+ if (typeof args.fingerprint !== "string") {
238
+ errs.push("fingerprint is required and must be a string");
239
+ } else if (!FINGERPRINT_PATTERN.test(args.fingerprint)) {
240
+ 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");
241
+ }
242
+ if (args.note !== undefined && typeof args.note !== "string") errs.push("note must be a string");
243
+ if (typeof args.note === "string" && args.note.length > 2000) errs.push("note must be 2000 characters or fewer");
244
+ if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
245
+
246
+ let entry;
247
+ try {
248
+ entry = kind === "ack" ? ackFinding(args.fingerprint, args.note) : dismissFinding(args.fingerprint, args.note);
249
+ } catch (err) {
250
+ return errorResult(`Failed to ${kind === "ack" ? "acknowledge" : "dismiss"} finding: ${err instanceof Error ? err.message : String(err)}`);
251
+ }
252
+
253
+ const warning = sessionSeenFingerprints.has(args.fingerprint)
254
+ ? null
255
+ : "This fingerprint was not returned by a residoo_scan or residoo_check call in this session -- it may not correspond to a real finding. Relay this warning rather than treating the response as proof it matched something real.";
256
+
257
+ return textResult({
258
+ fingerprint: entry.fingerprint, at: entry.at, note: entry.note || null,
259
+ status: kind === "ack" ? "acked" : "dismissed", ledgerFile: entry.file, warning,
260
+ summary: `${kind === "ack" ? "Acknowledged" : "Dismissed"} ${entry.fingerprint} at ${entry.at}.`,
261
+ });
262
+ }
263
+
264
+ const tools = new Map();
265
+ tools.set("residoo_scan", {
266
+ name: "residoo_scan",
267
+ description: "Run a fresh, read-only secret scan across every AI coding agent transcript store residoo knows about on this machine (or, if projectDir is given, across one project's committed agent artifacts -- transcripts, agent configs, .env files -- instead), merged with the local rotation ledger so each distinct finding also shows whether it is pending, already acknowledged as rotated, or dismissed as not a real secret. Performs real local disk reads only (can take a few seconds on a machine with many/large transcripts); makes zero network calls and modifies nothing. Every secret is always returned as a short redacted preview (first/last 4 characters) -- the raw value is never included anywhere in the response. Use this for 'do I have any leaked secrets right now' or 'give me the full current picture.' For 'what is new since I last checked in this conversation', call residoo_check instead -- it is much cheaper and only reports newly-appeared findings, not everything on disk.",
268
+ inputSchema: {
269
+ type: "object",
270
+ properties: {
271
+ projectDir: { type: "string", description: "Absolute path to a project/repo directory to scan instead of the machine-wide transcript stores (same as `residoo scan --project <dir>`). Omit for the default machine-wide scan." },
272
+ includeNoisy: { type: "boolean", default: false, description: "Also run residoo's two low-confidence heuristic rules (generic password/secret assignments) -- catches more, false-positives more. Off by default." },
273
+ includeSuppressed: { type: "boolean", default: false, description: "Include matches normally hidden because they look like vendor-documented example values or placeholder text. Off by default." },
274
+ maxEntries: { type: "integer", minimum: 1, maximum: 200, default: 25, description: "Cap on distinct findings returned in full detail, pending-first. Counts in the response are always exact even when the entry list is truncated." },
275
+ },
276
+ required: [],
277
+ additionalProperties: false,
278
+ },
279
+ handler: handleScan,
280
+ });
281
+ tools.set("residoo_check", {
282
+ name: "residoo_check",
283
+ description: "Report only what is NEW since the last time this tool was called in this conversation (or since the server started, on the first call). Backed by the same incremental engine as `residoo watch`, but called once per invocation instead of running continuously -- it tails newly-appended bytes and re-reads only files that changed, never a full disk crawl, so it is much cheaper than residoo_scan for a repeat check later in the same session. On the very FIRST call, it silently establishes a baseline and reports zero new findings by design -- this means 'watch just started,' not 'nothing is wrong'; the response's firstCheckThisSession field tells you which case you are in, and you should say so if it is true rather than implying a clean result. Call residoo_scan for a full picture of everything currently on disk. Never makes network calls, never modifies anything.",
284
+ inputSchema: {
285
+ type: "object",
286
+ properties: {
287
+ includeNoisy: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
288
+ includeSuppressed: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
289
+ maxEntries: { type: "integer", minimum: 1, maximum: 200, default: 25, description: "Cap on new findings / re-exposures returned in full detail. Counts are always exact even when truncated." },
290
+ },
291
+ required: [],
292
+ additionalProperties: false,
293
+ },
294
+ handler: handleCheck,
295
+ });
296
+ tools.set("residoo_explain", {
297
+ name: "residoo_explain",
298
+ description: "Look up residoo's rotation runbook for one detection rule id (e.g. github_pat, aws_access_key_id) -- what the credential is, where to rotate/revoke it in the vendor's console, and numbered steps. Pure local lookup against residoo's built-in guidance table; no network calls, no prior scan needed. Pass the exact ruleId from a finding returned by residoo_scan or residoo_check -- do not guess one. Omit ruleId to get the full list of every rule id residoo has guidance for with a one-line label each. If a rule id is not recognized, this still returns a response (never errors) -- an honest generic fallback with known: false.",
299
+ inputSchema: {
300
+ type: "object",
301
+ properties: {
302
+ ruleId: { type: "string", description: "A rule id from a prior finding's ruleId field. Omit to list every known rule id instead." },
303
+ },
304
+ required: [],
305
+ additionalProperties: false,
306
+ },
307
+ handler: handleExplain,
308
+ });
309
+ tools.set("residoo_ack", {
310
+ name: "residoo_ack",
311
+ description: "Record that the credential behind one specific finding has been rotated. This ONLY appends an entry to residoo's local rotation ledger (~/.residoo/rotations.json) -- the same additive, non-destructive audit file `residoo ack` writes from a terminal. It never touches, edits, or deletes the transcript file the secret was found in, and it does not rotate or revoke the credential itself -- the human still has to go do that at the vendor; use residoo_explain first if they need the steps. fingerprint MUST be copied verbatim from a fingerprint field returned by a prior residoo_scan or residoo_check call in this conversation -- never construct, guess, or reformat one; it is a hash, not something you can compute. Acknowledging a fingerprint that does not match any real finding silently records a no-op entry rather than erroring, which is why the response includes a warning field when the fingerprint was not seen earlier this session -- relay that warning to the user rather than treating a clean-looking response as proof it matched something real. note is optional free text; it is sanitized and length-capped server-side, but treat it as logged and do not put an actual secret value in it.",
312
+ inputSchema: {
313
+ type: "object",
314
+ properties: {
315
+ fingerprint: { type: "string", pattern: "^rf1-[0-9a-f]{32}$", description: "Exact fingerprint string from a prior scan/check finding. Never invent one." },
316
+ note: { type: "string", maxLength: 2000, description: "Optional note on how/when it was rotated. Server-side sanitization caps this further and redacts any accidental secret-shaped text." },
317
+ },
318
+ required: ["fingerprint"],
319
+ additionalProperties: false,
320
+ },
321
+ handler: (args) => resolveTool("ack", args),
322
+ });
323
+ tools.set("residoo_dismiss", {
324
+ name: "residoo_dismiss",
325
+ description: "Record that one specific finding was reviewed and determined NOT to be a real secret (a test fixture, an already-dead example string, a vendor sample not on residoo's built-in suppression list) -- distinct from residoo_ack, which means a real credential was rotated. Same ledger, same fingerprint-must-come-from-a-prior-scan-or-check rule, same non-destructive guarantee (only appends to ~/.residoo/rotations.json; never touches the scanned file).",
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ fingerprint: { type: "string", pattern: "^rf1-[0-9a-f]{32}$", description: "Exact fingerprint string from a prior scan/check finding. Never invent one." },
330
+ note: { type: "string", maxLength: 2000, description: "Optional note on why it was dismissed. Server-side sanitization caps this further and redacts any accidental secret-shaped text." },
331
+ },
332
+ required: ["fingerprint"],
333
+ additionalProperties: false,
334
+ },
335
+ handler: (args) => resolveTool("dismiss", args),
336
+ });
337
+
338
+ return tools;
339
+ }
340
+
341
+ module.exports = { buildTools };