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 +50 -2
- package/package.json +1 -1
- package/src/cli.js +55 -0
- package/src/mcp.js +266 -0
- package/src/mcpTools.js +341 -0
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.
|
|
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.
|
|
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.
|
|
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 };
|
package/src/mcpTools.js
ADDED
|
@@ -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 };
|