residoo 0.3.8 → 0.4.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
@@ -121,13 +121,18 @@ won't be built into the tool that writes it.
121
121
  - Redacts everything in its own output. You get a shape and a first/last-4
122
122
  preview, never the real value, including in `--json` mode. A decoded or
123
123
  rejoined secret is redacted exactly like a plain one.
124
- - Every report opens with the exact version and timestamp it was run with
125
- (`residoo v0.3.8 · scanned 2026-01-01 12:00`; `--json` carries the same
126
- as `residooVersion`/`scannedAt`), so a report pasted or screenshotted
127
- later never leaves you guessing which build produced it. On an
128
- interactive terminal, a lightweight spinner shows scan progress on
129
- stderr; it is a complete no-op when stdout/stderr are piped, redirected,
130
- or run in CI, so it can never interleave with `--json`/`--sarif` output.
124
+ - On an interactive terminal, prints who it is and where it lives before
125
+ scanning starts (`residoo v0.4.0 · find secrets your AI coding agent left
126
+ on disk` plus the repo URL), then a live spinner naming the current file
127
+ as it scans. Every report also opens with the exact version and timestamp
128
+ it was run with (`residoo v0.4.0 · scanned 2026-01-01 12:00`; `--json`
129
+ carries the same as `residooVersion`/`scannedAt`), so a report pasted or
130
+ screenshotted later never leaves you guessing which build produced it.
131
+ When there are findings, the report closes with a "Next steps" pointer to
132
+ `--json` and `--seal`. All of the interactive chatter (the intro, the
133
+ spinner) goes to stderr only and is a complete no-op when stdout/stderr
134
+ are piped, redirected, or run in CI, so none of it can ever interleave
135
+ with `--json`/`--sarif` output.
131
136
  - `--sarif` emits SARIF 2.1.0 for GitHub code scanning's Security tab and
132
137
  inline pull-request annotations, the same format gitleaks/trufflehog/
133
138
  agentsweep already speak, so residoo's own Action and pre-commit hook plug
@@ -292,12 +297,21 @@ with the way out:
292
297
  type: where to revoke, the steps, and what revocation actually does at
293
298
  that vendor. `residoo explain --list` shows the whole catalogue.
294
299
  - **`residoo ack <fingerprint>`** records that you rotated one finding.
300
+ **`residoo dismiss <fingerprint>`** records that you determined it was
301
+ never a real secret (a test fixture, a vendor example not already
302
+ recognized), a separate resolution from ack, since nothing was rotated.
295
303
  Every finding carries a stable fingerprint (derived only from
296
304
  already-redacted material, so the ledger can never leak), shown in the
297
- report and in `--json`. Acknowledged findings are reported as such on the
298
- next scan instead of re-alarming forever. The ledger lives at
299
- `~/.residoo/rotations.json`: residoo's own file, written atomically, ack
300
- notes redacted through the same pipeline as previews.
305
+ report and in `--json`. Acked and dismissed findings are both reported as
306
+ resolved on the next scan instead of re-alarming forever. Both live in the
307
+ same ledger at `~/.residoo/rotations.json`: residoo's own file, written
308
+ atomically, notes redacted through the same pipeline as previews.
309
+ - **A "Recommended actions" summary leads the report**, before the detailed
310
+ findings breakdown: how many *distinct* values still need a decision,
311
+ versus how many are already resolved. A machine with a lot of history can
312
+ report hundreds of raw findings that are really a handful of distinct
313
+ values echoed repeatedly; the summary is built around what's actually left
314
+ to triage, not the raw count.
301
315
  - **Order matters, and the report says so when it does.** The ChainDrop
302
316
  campaign (Aug 2026) shipped a token monitor that fires an attacker payload
303
317
  the moment the stolen GitHub token is revoked. When one scan finds both
@@ -305,9 +319,9 @@ with the way out:
305
319
  the planted persistence first and rotate second, because "rotate
306
320
  everything now" advice can itself trigger the damage.
307
321
 
308
- Acks change what the report says, never what CI does: `--fail-on-find`
309
- fails on every finding, acknowledged or not, unless you explicitly pass
310
- `--allow-acked` (integrity warnings always fail either way).
322
+ Acks and dismissals change what the report says, never what CI does:
323
+ `--fail-on-find` fails on every finding, resolved or not, unless you
324
+ explicitly pass `--allow-acked` (integrity warnings always fail either way).
311
325
 
312
326
  ## CI and pre-commit
313
327
 
@@ -324,7 +338,7 @@ As a GitHub Action (this repository doubles as a composite action):
324
338
  ```yaml
325
339
  steps:
326
340
  - uses: actions/checkout@v4
327
- - uses: dandovdub/residoo@v0.3.8
341
+ - uses: dandovdub/residoo@v0.4.0
328
342
  ```
329
343
 
330
344
  As a pre-commit hook:
@@ -332,7 +346,7 @@ As a pre-commit hook:
332
346
  ```yaml
333
347
  repos:
334
348
  - repo: https://github.com/dandovdub/residoo
335
- rev: v0.3.8
349
+ rev: v0.4.0
336
350
  hooks:
337
351
  - id: residoo
338
352
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.3.8",
3
+ "version": "0.4.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
@@ -5,10 +5,10 @@ const fs = require("fs");
5
5
  const crypto = require("crypto");
6
6
  const { availableSources, ALL_SOURCES } = require("./sources");
7
7
  const { scan, emptyResult } = require("./scan");
8
- const { render, renderIntegrity, renderJson, renderSarif, makeProgressReporter } = require("./report");
8
+ const { render, renderIntegrity, renderJson, renderSarif, makeProgressReporter, printIntro } = require("./report");
9
9
  const { checkIntegrity } = require("./integrity");
10
10
  const {
11
- ROTATION_GUIDANCE, guidanceFor, loadAcks, ackFinding, renderRotation,
11
+ ROTATION_GUIDANCE, guidanceFor, loadAcks, loadDismissed, ackFinding, dismissFinding, renderRotation,
12
12
  } = require("./rotation");
13
13
 
14
14
  /**
@@ -48,9 +48,11 @@ const HELP = `residoo: find secrets leaking through your AI agent's session hist
48
48
  path, verified against their docs, because a leaked key that is found but
49
49
  never rotated is still leaked (64% of leaked secrets stay valid for years).
50
50
  "residoo explain <rule-id>" prints the full runbook for one credential
51
- type; "residoo ack <fingerprint>" records that you rotated one, in
52
- ~/.residoo/rotations.json, the only file residoo ever writes outside an
53
- explicit --seal.
51
+ type; "residoo ack <fingerprint>" records that you rotated one, and
52
+ "residoo dismiss <fingerprint>" records that you determined it was never a
53
+ real secret (a test fixture, a vendor example not already recognized).
54
+ Both are recorded in ~/.residoo/rotations.json, the only file residoo
55
+ ever writes outside an explicit --seal.
54
56
 
55
57
  Scanning makes NO network calls and changes nothing on disk. Findings are
56
58
  redacted in every output format. Sealing (--seal) writes NEW encrypted
@@ -60,6 +62,7 @@ Usage:
60
62
  residoo scan [options]
61
63
  residoo explain <rule-id> (or: residoo explain --list)
62
64
  residoo ack <fingerprint> [--note <text>]
65
+ residoo dismiss <fingerprint> [--note <text>]
63
66
  residoo unseal <vault-dir> [--restore <n> --out <path>]
64
67
 
65
68
  Scan options:
@@ -85,23 +88,29 @@ Scan options:
85
88
  findings and integrity WARNINGS count; integrity
86
89
  info-level review items do not
87
90
  --allow-acked with --fail-on-find: findings whose fingerprint was
88
- acknowledged via "residoo ack" no longer fail the
89
- run; pending findings and integrity warnings still
90
- do. Without this flag, --fail-on-find fails on
91
- every finding, acknowledged or not.
91
+ acknowledged via "residoo ack" OR dismissed via
92
+ "residoo dismiss" no longer fail the run; pending
93
+ findings and integrity warnings still do. Without
94
+ this flag, --fail-on-find fails on every finding,
95
+ acked, dismissed, or not.
92
96
  --no-integrity skip the integrity checks (planted hooks, dropper
93
97
  files, auto-run tasks, hidden Unicode)
94
98
  --no-color disable ANSI colour
95
99
 
96
100
  Rotation:
97
- residoo explain <rule-id> full rotation runbook for one detection rule
98
- (where to revoke, steps, what revocation does)
99
- residoo explain --list every rule id with its credential label
100
- residoo ack <fingerprint> mark one finding's rotation done; fingerprints
101
- appear next to findings in the report and in
102
- --json. Optional --note <text> is stored with
103
- the acknowledgement (redacted if it matches a
104
- secret pattern).
101
+ residoo explain <rule-id> full rotation runbook for one detection rule
102
+ (where to revoke, steps, what revocation does)
103
+ residoo explain --list every rule id with its credential label
104
+ residoo ack <fingerprint> mark one finding's rotation done; fingerprints
105
+ appear next to findings in the report and in
106
+ --json. Optional --note <text> is stored with
107
+ the acknowledgement (redacted if it matches a
108
+ secret pattern).
109
+ residoo dismiss <fingerprint> mark one finding as reviewed and NOT a real
110
+ secret (a test fixture, a vendor example not
111
+ already recognized, etc.), distinct from ack:
112
+ nothing was rotated, there was nothing to
113
+ rotate. Same --note handling as ack.
105
114
 
106
115
  Seal options (used with scan):
107
116
  --seal after scanning, encrypt every transcript that carried a
@@ -367,6 +376,41 @@ function runAck(args) {
367
376
  return 0;
368
377
  }
369
378
 
379
+ /**
380
+ * Record that one finding was reviewed and determined NOT to be a real
381
+ * secret (a test fixture, a value used to verify residoo's own detection,
382
+ * a vendor example not already on the built-in suppression list, etc.) —
383
+ * distinct from `ack`, which means "I rotated a real credential." Without
384
+ * this, the only way to stop a confirmed-fake finding from reappearing
385
+ * every scan was to `ack` it, which is semantically wrong (nothing was
386
+ * rotated) and reads misleadingly in the rotation ledger.
387
+ */
388
+ function runDismiss(args) {
389
+ const fp = args[1] && !args[1].startsWith("--") ? args[1] : null;
390
+ if (!fp) {
391
+ process.stderr.write("usage: residoo dismiss <fingerprint> [--note <text>]\n" +
392
+ "Fingerprints (rf1-...) are shown next to findings in the scan report and in --json.\n");
393
+ return 2;
394
+ }
395
+ let res;
396
+ try {
397
+ res = dismissFinding(fp, argValue(args, "--note"));
398
+ } catch (err) {
399
+ process.stderr.write(`residoo: ${err instanceof Error ? err.message : String(err)}\n`);
400
+ return 2;
401
+ }
402
+ process.stdout.write(
403
+ `Dismissed ${res.fingerprint} at ${res.at}` +
404
+ (res.note ? ` with note: ${res.note}` : "") + "\n" +
405
+ "The next scan reports this finding as dismissed instead of pending, and it is\n" +
406
+ "excluded from --fail-on-find the same way an acked finding is with --allow-acked.\n" +
407
+ "(dismiss is stateless, same as ack: it cannot check this fingerprint against a\n" +
408
+ "scan, so a mistyped one is recorded too; the intended finding would then still\n" +
409
+ "show as pending on the next scan.)\n"
410
+ );
411
+ return 0;
412
+ }
413
+
370
414
  async function main(argv) {
371
415
  const args = argv.slice(2);
372
416
  if (args.includes("-h") || args.includes("--help") || args.length === 0) {
@@ -378,6 +422,7 @@ async function main(argv) {
378
422
  if (cmd === "unseal") return runUnseal(args);
379
423
  if (cmd === "explain") return runExplain(args);
380
424
  if (cmd === "ack") return runAck(args);
425
+ if (cmd === "dismiss") return runDismiss(args);
381
426
  if (cmd !== "scan") {
382
427
  process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
383
428
  return 2;
@@ -404,6 +449,7 @@ async function main(argv) {
404
449
  // mutated env var would leak past this one invocation and silently kill
405
450
  // color for a later call that never asked for that.
406
451
  const noColor = args.includes("--no-color");
452
+ printIntro(noColor);
407
453
 
408
454
  // Integrity runs by default: a scan that reports "no secrets leaked" while
409
455
  // a planted SessionStart hook sits ready to re-leak them next session is
@@ -466,9 +512,11 @@ async function main(argv) {
466
512
  sources = availableSources();
467
513
  }
468
514
 
469
- // loadAcks degrades to {} (loudly, on stderr) if the state file is corrupt,
470
- // so a broken ack ledger can never block or distort a scan.
515
+ // loadAcks/loadDismissed degrade to {} (loudly, on stderr) if the state
516
+ // file is corrupt, so a broken rotation ledger can never block or distort
517
+ // a scan.
471
518
  const acks = loadAcks();
519
+ const dismissed = loadDismissed();
472
520
 
473
521
  if (sources.length === 0) {
474
522
  const empty = emptyResult();
@@ -481,7 +529,7 @@ async function main(argv) {
481
529
  // A --json caller (CI, a script piping into jq) must always get valid JSON
482
530
  // on stdout, even on the "nothing to scan" path — a plain-text message on
483
531
  // stderr with exit 0 silently breaks that contract.
484
- process.stdout.write(renderJson(empty, integrity, renderRotation([], acks)) + "\n");
532
+ process.stdout.write(renderJson(empty, integrity, renderRotation([], acks, dismissed)) + "\n");
485
533
  } else {
486
534
  process.stderr.write(
487
535
  "No known transcript sources found on this machine.\n" +
@@ -494,11 +542,11 @@ async function main(argv) {
494
542
  return failOnFind && integrityWarnCount(integrity) > 0 ? 1 : 0;
495
543
  }
496
544
 
497
- const progress = makeProgressReporter();
545
+ const progress = makeProgressReporter(noColor);
498
546
  const result = await scan({ sources, includeNoisy, includeSuppressed, onProgress: progress.onProgress });
499
547
  progress.stop();
500
548
  const integrity = wantsIntegrity ? runIntegrity() : null;
501
- const rotation = renderRotation(result.findings, acks);
549
+ const rotation = renderRotation(result.findings, acks, dismissed);
502
550
  process.stdout.write((wantsSarif
503
551
  ? renderSarif(result)
504
552
  : wantsJson
package/src/report.js CHANGED
@@ -32,35 +32,58 @@ function ageDays(mtimeMs) {
32
32
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
33
33
 
34
34
  /**
35
- * A minimal progress indicator for the scan phase, wired to scan()'s own
35
+ * Printed once, before scanning starts: what this is, what version, where
36
+ * it lives. Answers exactly the question a first-time (or every-time)
37
+ * reader has before results ever appear, without waiting for the report.
38
+ * Same TTY gate and same stream as the progress spinner below and for the
39
+ * same reason: stderr only, so a --json/--sarif consumer's stdout is
40
+ * untouched, and a complete no-op under redirection, piping, or CI.
41
+ */
42
+ function printIntro(noColor) {
43
+ if (!process.stderr.isTTY) return;
44
+ const paint = makePaint(noColor);
45
+ const { version } = require("../package.json");
46
+ process.stderr.write(
47
+ paint(c.bold + c.cyan, `residoo v${version}`) +
48
+ paint(c.dim, " · find secrets your AI coding agent left on disk\n") +
49
+ paint(c.dim, "https://github.com/dandovdub/residoo\n\n")
50
+ );
51
+ }
52
+
53
+ /**
54
+ * A progress indicator for the scan phase, wired to scan()'s own
36
55
  * onProgress callback. Writes to STDERR only, never stdout: --json/--sarif
37
56
  * consumers pipe stdout into a parser, and a spinner corrupting that would
38
57
  * be a much worse bug than not having one. Gated on stderr actually being a
39
58
  * TTY, so it is a complete no-op under redirection, piping, or CI, exactly
40
59
  * the contexts where carriage-return spam in a captured log would be
41
- * useless or actively annoying, not merely invisible. `stop()` clears the
42
- * line so whatever prints next (the report, on stdout, is unaffected
43
- * either way since this never touched stdout, but a plain-text stderr
44
- * reader watching live should not see a stale line lingering) starts
60
+ * useless or actively annoying, not merely invisible. Shows the actual
61
+ * current file (basename only, through the same safeBasename() every other
62
+ * displayed path in this report goes through control bytes stripped,
63
+ * invisible code points made visible), not just a running count: real
64
+ * signal, not just motion, and genuinely useful if a scan stalls on one
65
+ * huge file. `stop()` clears the line so whatever prints next starts
45
66
  * clean.
46
67
  */
47
- function makeProgressReporter() {
68
+ function makeProgressReporter(noColor) {
48
69
  if (!process.stderr.isTTY) return { onProgress: null, stop() {} };
70
+ const paint = makePaint(noColor);
49
71
  let count = 0;
50
72
  let lastWriteMs = 0;
51
73
  let lastLineLen = 0;
52
74
  let frame = 0;
53
- const write = (s) => {
75
+ const write = (s, visibleLen) => {
54
76
  process.stderr.write("\r" + " ".repeat(lastLineLen) + "\r" + s);
55
- lastLineLen = s.length;
77
+ lastLineLen = visibleLen;
56
78
  };
57
- const onProgress = ({ source }) => {
79
+ const onProgress = ({ source, file }) => {
58
80
  count++;
59
81
  const now = Date.now();
60
82
  if (now - lastWriteMs < 80) return; // throttled: avoid flicker on a fast scan
61
83
  lastWriteMs = now;
62
84
  frame = (frame + 1) % SPINNER_FRAMES.length;
63
- write(`${SPINNER_FRAMES[frame]} scanning… ${count} file${count === 1 ? "" : "s"} checked (${source})`);
85
+ const label = `scanning ${source}… ${count} file${count === 1 ? "" : "s"} ${safeBasename(file)}`;
86
+ write(paint(c.bold + c.cyan, SPINNER_FRAMES[frame]) + " " + paint(c.dim, label), 2 + label.length);
64
87
  };
65
88
  const stop = () => { if (lastLineLen > 0) process.stderr.write("\r" + " ".repeat(lastLineLen) + "\r"); };
66
89
  return { onProgress, stop };
@@ -120,9 +143,13 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
120
143
  // raw values, while these entries dedupe fingerprints (which include the
121
144
  // basename, so one value in two differently-named files is two rotations to
122
145
  // track). Two counts under one word would read as a contradiction.
146
+ const resolvedNote = [
147
+ counts.acked > 0 ? `${counts.acked} acknowledged` : null,
148
+ counts.dismissed > 0 ? `${counts.dismissed} dismissed` : null,
149
+ ].filter(Boolean).join(", ");
123
150
  push(paint(c.bold, "Rotation:") +
124
151
  ` ${counts.pending} of ${counts.distinct} rotation${counts.distinct === 1 ? "" : "s"} pending` +
125
- (counts.acked > 0 ? ` (${counts.acked} acknowledged)` : ""));
152
+ (resolvedNote ? ` (${resolvedNote})` : ""));
126
153
  if (showAdvisory) {
127
154
  const wrapped = wrapText(ROTATION_ORDER_ADVISORY, 72, " ");
128
155
  push(` ${paint(c.red + c.bold, "⚠ " + wrapped[0])}`);
@@ -132,11 +159,17 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
132
159
  // dump. Everything elided here is in --json in full.
133
160
  const MAX_SHOWN = 12;
134
161
  const shown = entries.slice(0, MAX_SHOWN);
162
+ const STATUS_TAG = {
163
+ pending: paint(c.yellow, "pending "),
164
+ acked: paint(c.green, "acked "),
165
+ dismissed: paint(c.dim, "dismissed"),
166
+ };
135
167
  for (const e of shown) {
136
- const tag = e.status === "pending" ? paint(c.yellow, "pending") : paint(c.green, "acked ");
137
- push(` ${tag} ${e.fingerprint} ${e.label}`);
168
+ push(` ${STATUS_TAG[e.status]} ${e.fingerprint} ${e.label}`);
138
169
  if (e.status === "acked") {
139
170
  push(paint(c.dim, ` acknowledged ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""}`));
171
+ } else if (e.status === "dismissed") {
172
+ push(paint(c.dim, ` dismissed ${e.ackedAt || "(no timestamp)"}${e.ackNote ? `: ${e.ackNote}` : ""} (not a real secret)`));
140
173
  } else {
141
174
  const g = e.guidance;
142
175
  const where = g.rotateUrl ? `rotate: ${g.rotateUrl}` : `where: ${g.consolePath}`;
@@ -146,7 +179,7 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
146
179
  if (entries.length > shown.length) {
147
180
  push(paint(c.dim, ` … and ${entries.length - shown.length} more; see --json for the full list`));
148
181
  }
149
- push(paint(c.dim, ` Full runbook: residoo explain <rule-id> · mark one rotated: residoo ack <fingerprint>`));
182
+ push(paint(c.dim, ` Full runbook: residoo explain <rule-id> · rotated: residoo ack <fp> · not a secret: residoo dismiss <fp>`));
150
183
  return lines.join("\n");
151
184
  }
152
185
 
@@ -264,6 +297,31 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
264
297
  push(paint(c.dim, ` ${filesScanned} files scanned (${(bytesScanned / 1024 / 1024).toFixed(1)} MB) · sources: ${sourcesScanned.join(", ")}`));
265
298
  push(paint(c.dim, ` oldest match ~${ageDays(oldest)}d old · most recent ~${ageDays(newest)}d old`) + suppressedNote);
266
299
  if (unreadableNote) push(unreadableNote);
300
+
301
+ // The practical, act-on-this-now summary, first, before the full
302
+ // rule-by-rule and file-by-file detail below: a raw count of findings
303
+ // (which can run into the hundreds on a machine with a lot of history) is
304
+ // not by itself a to-do list. What actually needs a person's attention is
305
+ // the count of DISTINCT values not yet triaged (rotated or dismissed) —
306
+ // everything else is either already handled or a re-exposure of a value
307
+ // already accounted for.
308
+ if (rotation && rotation.counts.distinct > 0) {
309
+ const { pending, distinct, acked, dismissed } = rotation.counts;
310
+ push();
311
+ push(paint(c.bold, "Recommended actions:"));
312
+ if (pending > 0) {
313
+ push(` ${paint(c.yellow, "→")} ${pending} of ${distinct} distinct value${distinct === 1 ? "" : "s"} ${pending === 1 ? "needs" : "need"} review: rotate the real ones (residoo ack), dismiss the rest (residoo dismiss)`);
314
+ } else {
315
+ push(` ${paint(c.green, "✓")} Nothing new to review; every distinct value here has already been triaged`);
316
+ }
317
+ const resolvedParts = [
318
+ acked > 0 ? `${acked} acknowledged` : null,
319
+ dismissed > 0 ? `${dismissed} dismissed` : null,
320
+ ].filter(Boolean);
321
+ if (resolvedParts.length > 0) {
322
+ push(paint(c.dim, ` ${resolvedParts.join(", ")} already, no action needed (see Rotation below for which)`));
323
+ }
324
+ }
267
325
  push();
268
326
 
269
327
  const sorted = [...byRule.entries()].sort((a, b) => b[1].items.length - a[1].items.length);
@@ -304,9 +362,13 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
304
362
  push(renderIntegrity(integrity, { noColor }));
305
363
  }
306
364
 
365
+ push();
366
+ push(paint(c.bold, "Next steps:"));
367
+ push(` residoo scan --json ${paint(c.dim, "machine-readable output, full detail")}`);
368
+ push(` residoo scan --seal ${paint(c.dim, "encrypt the affected files into a local vault (originals untouched)")}`);
307
369
  push();
308
370
  push(paint(c.dim, "Values are redacted in this report (first/last 4 characters only). Nothing scanned"));
309
- push(paint(c.dim, "here left your machine; residoo makes no network calls. Run with --json for full detail."));
371
+ push(paint(c.dim, "here left your machine; residoo makes no network calls."));
310
372
 
311
373
  return lines.join("\n");
312
374
  }
@@ -450,4 +512,4 @@ function renderSarif(result) {
450
512
  }, null, 2);
451
513
  }
452
514
 
453
- module.exports = { render, renderIntegrity, renderRotationSection, renderJson, renderSarif, makeProgressReporter };
515
+ module.exports = { render, renderIntegrity, renderRotationSection, renderJson, renderSarif, makeProgressReporter, printIntro };
package/src/rotation.js CHANGED
@@ -668,47 +668,86 @@ function statePath() {
668
668
  }
669
669
 
670
670
  /**
671
- * Load the ack map: { "<fingerprint>": { at, note } }. Missing file is the
672
- * normal first-run case and returns {} silently. A corrupt or unreadable
673
- * file returns {} too, but LOUDLY: one note on stderr, because "your acks
674
- * are gone" must never be silent, and because the next ackFinding() will
671
+ * Parse one map (acks, or dismissed) out of the already-JSON-parsed state
672
+ * file body. Shared by loadFullState() for both keys: same validation, same
673
+ * per-entry degrade-not-discard behavior, so a hand-edited or foreign
674
+ * ledger loses only its malformed entries, never the whole file.
675
+ */
676
+ function parseFpMap(raw) {
677
+ const out = {};
678
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
679
+ for (const [fp, v] of Object.entries(raw)) {
680
+ if (!FINGERPRINT_RE.test(fp)) continue;
681
+ if (!v || typeof v !== "object") continue;
682
+ // Control bytes stripped on READ as well as on write: this file sits on
683
+ // disk between the two, and a hand-edited or foreign ledger must not be
684
+ // able to put a terminal escape into the report via a note.
685
+ out[fp] = {
686
+ at: typeof v.at === "string" ? stripControlChars(v.at) : null,
687
+ note: typeof v.note === "string" ? stripControlChars(v.note) : null,
688
+ };
689
+ }
690
+ return out;
691
+ }
692
+
693
+ /**
694
+ * Load the full rotation state: { acks, dismissed }, both shaped
695
+ * { "<fingerprint>": { at, note } }. Missing file is the normal first-run
696
+ * case and returns both empty silently. A corrupt or unreadable file
697
+ * returns both empty too, but LOUDLY: one note on stderr, because "your
698
+ * acks are gone" must never be silent, and because the next write will
675
699
  * start a fresh store over the corrupt one.
676
700
  */
677
- function loadAcks({ file = statePath() } = {}) {
701
+ function loadFullState({ file = statePath() } = {}) {
702
+ const empty = { acks: {}, dismissed: {} };
678
703
  let text;
679
704
  try {
680
705
  text = fs.readFileSync(file, "utf-8");
681
706
  } catch (err) {
682
- if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return {};
683
- process.stderr.write(`residoo: rotation state ${path.basename(file)} could not be read; continuing with no acknowledgements\n`);
684
- return {};
707
+ if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return empty;
708
+ process.stderr.write(`residoo: rotation state ${path.basename(file)} could not be read; continuing with no acknowledgements or dismissals\n`);
709
+ return empty;
685
710
  }
686
711
  let parsed;
687
712
  try {
688
713
  parsed = JSON.parse(text);
689
714
  } catch {
690
- process.stderr.write(`residoo: rotation state ${path.basename(file)} is corrupt; continuing with no acknowledgements (a new acknowledgement will start a fresh store)\n`);
691
- return {};
715
+ process.stderr.write(`residoo: rotation state ${path.basename(file)} is corrupt; continuing with no acknowledgements or dismissals (the next write will start a fresh store)\n`);
716
+ return empty;
692
717
  }
693
- if (!parsed || typeof parsed !== "object" || parsed.v !== 1 || !parsed.acks || typeof parsed.acks !== "object" || Array.isArray(parsed.acks)) {
694
- process.stderr.write(`residoo: rotation state ${path.basename(file)} has an unrecognized shape; continuing with no acknowledgements\n`);
695
- return {};
718
+ if (!parsed || typeof parsed !== "object" || parsed.v !== 1) {
719
+ process.stderr.write(`residoo: rotation state ${path.basename(file)} has an unrecognized shape; continuing with no acknowledgements or dismissals\n`);
720
+ return empty;
696
721
  }
697
- // Only well-formed entries under well-formed keys survive: state written
698
- // by a future version (or hand-edited) degrades per-entry, not per-file.
699
- const acks = {};
700
- for (const [fp, v] of Object.entries(parsed.acks)) {
701
- if (!FINGERPRINT_RE.test(fp)) continue;
702
- if (!v || typeof v !== "object") continue;
703
- // Control bytes stripped on READ as well as on write: this file sits on
704
- // disk between the two, and a hand-edited or foreign ledger must not be
705
- // able to put a terminal escape into the report via an ack note.
706
- acks[fp] = {
707
- at: typeof v.at === "string" ? stripControlChars(v.at) : null,
708
- note: typeof v.note === "string" ? stripControlChars(v.note) : null,
709
- };
722
+ // dismissed is a later addition to this same file (v stays 1: additive,
723
+ // tolerant of a file written by an older residoo that never had this key).
724
+ return { acks: parseFpMap(parsed.acks), dismissed: parseFpMap(parsed.dismissed) };
725
+ }
726
+
727
+ /** Backward-compatible: the acks half of loadFullState(), same call shape as before dismiss existed. */
728
+ function loadAcks({ file = statePath() } = {}) {
729
+ return loadFullState({ file }).acks;
730
+ }
731
+
732
+ /** The dismissed half of loadFullState(). */
733
+ function loadDismissed({ file = statePath() } = {}) {
734
+ return loadFullState({ file }).dismissed;
735
+ }
736
+
737
+ /** Atomic write of the full state: temp file in the same directory, then rename; 0o600/0o700, same as before. */
738
+ function writeFullState(file, { acks, dismissed }) {
739
+ const dir = path.dirname(file);
740
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
741
+ const tmp = path.join(dir, `.rotations.json.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
742
+ const body = JSON.stringify({ v: 1, acks, dismissed }, null, 2) + "\n";
743
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
744
+ try {
745
+ fs.renameSync(tmp, file);
746
+ } catch (err) {
747
+ // The rename failing must not strand a temp file next to the state.
748
+ try { fs.unlinkSync(tmp); } catch {}
749
+ throw err;
710
750
  }
711
- return acks;
712
751
  }
713
752
 
714
753
  /**
@@ -732,42 +771,44 @@ function sanitizeNote(note) {
732
771
  }
733
772
 
734
773
  /**
735
- * Record that the user acknowledged (rotated / accepted) one finding.
774
+ * Record that the user acknowledged (rotated) one finding, or dismissed it
775
+ * (decided it was never a real secret, a test fixture, a vendor example not
776
+ * already on the suppression list, etc.) — two DIFFERENT resolutions of the
777
+ * same "this is no longer pending" question, kept as separate maps in one
778
+ * file rather than a single status field: acked and dismissed have
779
+ * different guidance implications (an acked finding's guidance stays
780
+ * relevant if you ever need to re-check the rotation; a dismissed one
781
+ * never needed guidance in the first place) and different --fail-on-find
782
+ * semantics may want to diverge later. Shared helper for both:
783
+ * ackFinding(fp, note) / dismissFinding(fp, note).
784
+ *
736
785
  * Atomic: temp file in the same directory, then rename; 0o600 on the file,
737
786
  * 0o700 on the directory, since even a redacted rotation ledger is nobody
738
787
  * else's business.
739
788
  *
740
- * Atomic is not serialized: two concurrent `residoo ack` runs each
741
- * load-modify-write, and the last rename wins, silently dropping the other
742
- * run's ack. Accepted as a single-writer design: acks are typed by a human
743
- * one at a time, the ledger is per-user state, and the failure direction is
744
- * fail-safe (a dropped ack reverts that finding to pending, never the
745
- * reverse). A lockfile would add a stale-lock recovery path for a race that
746
- * a person cannot realistically produce.
789
+ * Atomic is not serialized: two concurrent `residoo ack`/`dismiss` runs
790
+ * each load-modify-write, and the last rename wins, silently dropping the
791
+ * other run's change. Accepted as a single-writer design: these are typed
792
+ * by a human one at a time, the ledger is per-user state, and the failure
793
+ * direction is fail-safe (a dropped entry reverts that finding to pending,
794
+ * never the reverse). A lockfile would add a stale-lock recovery path for
795
+ * a race that a person cannot realistically produce.
747
796
  */
748
- function ackFinding(fp, note, { file = statePath() } = {}) {
797
+ function resolveFinding(kind, fp, note, { file = statePath() } = {}) {
749
798
  if (typeof fp !== "string" || !FINGERPRINT_RE.test(fp)) {
750
- throw new TypeError("ackFinding expects a fingerprint from fingerprintFinding() (rf1-<32 hex>)");
799
+ throw new TypeError(`${kind}Finding expects a fingerprint from fingerprintFinding() (rf1-<32 hex>)`);
751
800
  }
752
- const acks = loadAcks({ file });
801
+ const state = loadFullState({ file });
753
802
  const entry = { at: new Date().toISOString(), note: sanitizeNote(note) };
754
- acks[fp] = entry;
755
-
756
- const dir = path.dirname(file);
757
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
758
- const tmp = path.join(dir, `.rotations.json.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
759
- const body = JSON.stringify({ v: 1, acks }, null, 2) + "\n";
760
- fs.writeFileSync(tmp, body, { mode: 0o600 });
761
- try {
762
- fs.renameSync(tmp, file);
763
- } catch (err) {
764
- // The rename failing must not strand a temp file next to the state.
765
- try { fs.unlinkSync(tmp); } catch {}
766
- throw err;
767
- }
803
+ const key = kind === "ack" ? "acks" : "dismissed";
804
+ state[key][fp] = entry;
805
+ writeFullState(file, state);
768
806
  return { fingerprint: fp, ...entry, file };
769
807
  }
770
808
 
809
+ function ackFinding(fp, note, opts) { return resolveFinding("ack", fp, note, opts); }
810
+ function dismissFinding(fp, note, opts) { return resolveFinding("dismiss", fp, note, opts); }
811
+
771
812
  // ── summaries for the report layer ──────────────────────────────────────────
772
813
 
773
814
  /**
@@ -776,42 +817,58 @@ function ackFinding(fp, note, { file = statePath() } = {}) {
776
817
  * across a transcript are one rotation to do, not five (the same
777
818
  * distinct-vs-re-exposed reasoning scan.js applies to counting).
778
819
  */
779
- function pendingSummary(findings, acks) {
820
+ function pendingSummary(findings, acks, dismissed = {}) {
780
821
  const list = Array.isArray(findings) ? findings : [];
781
822
  const ackMap = acks && typeof acks === "object" ? acks : {};
823
+ const dismissMap = dismissed && typeof dismissed === "object" ? dismissed : {};
782
824
  const statuses = [];
783
- const distinct = new Map();
825
+ const distinctStatus = new Map(); // fp -> "acked" | "dismissed" | "pending"
784
826
  for (const f of list) {
785
827
  const fp = fingerprintFinding(f);
786
828
  const ack = ackMap[fp] || null;
829
+ const dismiss = dismissMap[fp] || null;
830
+ // Precedence when a fingerprint somehow has both (not reachable through
831
+ // the CLI today, but the state file is hand-editable): acked wins. "I
832
+ // rotated it" is the more thorough resolution of the two, and reverting
833
+ // to "acked" from a stray dismissed entry is the fail-safe direction —
834
+ // it keeps guidance attached rather than silently dropping a real
835
+ // rotation's record.
836
+ const status = ack ? "acked" : dismiss ? "dismissed" : "pending";
837
+ const resolved = ack || dismiss;
787
838
  statuses.push({
788
839
  fingerprint: fp,
789
- status: ack ? "acked" : "pending",
790
- ackedAt: ack ? ack.at : null,
791
- ackNote: ack ? ack.note : null,
840
+ status,
841
+ ackedAt: resolved ? resolved.at : null,
842
+ ackNote: resolved ? resolved.note : null,
792
843
  });
793
- if (!distinct.has(fp)) distinct.set(fp, !!ack);
844
+ if (!distinctStatus.has(fp)) distinctStatus.set(fp, status);
845
+ }
846
+ let acked = 0, dismissedCount = 0;
847
+ for (const status of distinctStatus.values()) {
848
+ if (status === "acked") acked++;
849
+ else if (status === "dismissed") dismissedCount++;
794
850
  }
795
- let acked = 0;
796
- for (const isAcked of distinct.values()) if (isAcked) acked++;
797
851
  return {
798
852
  counts: {
799
853
  findings: list.length,
800
- distinct: distinct.size,
801
- pending: distinct.size - acked,
854
+ distinct: distinctStatus.size,
855
+ pending: distinctStatus.size - acked - dismissedCount,
802
856
  acked,
857
+ dismissed: dismissedCount,
803
858
  },
804
859
  statuses,
805
860
  };
806
861
  }
807
862
 
863
+ const STATUS_ORDER = { pending: 0, acked: 1, dismissed: 2 };
864
+
808
865
  /**
809
866
  * Pure data for the report layer: one entry per distinct fingerprint, with
810
867
  * rotation guidance attached and pending entries first. Prints nothing.
811
868
  */
812
- function renderRotation(findings, acks) {
869
+ function renderRotation(findings, acks, dismissed = {}) {
813
870
  const list = Array.isArray(findings) ? findings : [];
814
- const { counts, statuses } = pendingSummary(list, acks);
871
+ const { counts, statuses } = pendingSummary(list, acks, dismissed);
815
872
 
816
873
  const byFp = new Map();
817
874
  for (let i = 0; i < list.length; i++) {
@@ -842,7 +899,7 @@ function renderRotation(findings, acks) {
842
899
  }
843
900
 
844
901
  const entries = [...byFp.values()].sort((a, b) => {
845
- if (a.status !== b.status) return a.status === "pending" ? -1 : 1;
902
+ if (a.status !== b.status) return STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
846
903
  if (a.ruleId !== b.ruleId) return a.ruleId < b.ruleId ? -1 : 1;
847
904
  return a.fingerprint < b.fingerprint ? -1 : 1;
848
905
  });
@@ -857,7 +914,9 @@ module.exports = {
857
914
  fingerprintFinding,
858
915
  statePath,
859
916
  loadAcks,
917
+ loadDismissed,
860
918
  ackFinding,
919
+ dismissFinding,
861
920
  pendingSummary,
862
921
  renderRotation,
863
922
  };