residoo 0.4.14 → 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 +91 -2
- package/package.json +1 -1
- package/src/cli.js +186 -0
- package/src/mcp.js +266 -0
- package/src/mcpTools.js +341 -0
- package/src/watch.js +640 -0
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 };
|