residoo 0.3.9 → 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
@@ -122,10 +122,10 @@ won't be built into the tool that writes it.
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
124
  - On an interactive terminal, prints who it is and where it lives before
125
- scanning starts (`residoo v0.3.9 · find secrets your AI coding agent left
125
+ scanning starts (`residoo v0.4.0 · find secrets your AI coding agent left
126
126
  on disk` plus the repo URL), then a live spinner naming the current file
127
127
  as it scans. Every report also opens with the exact version and timestamp
128
- it was run with (`residoo v0.3.9 · scanned 2026-01-01 12:00`; `--json`
128
+ it was run with (`residoo v0.4.0 · scanned 2026-01-01 12:00`; `--json`
129
129
  carries the same as `residooVersion`/`scannedAt`), so a report pasted or
130
130
  screenshotted later never leaves you guessing which build produced it.
131
131
  When there are findings, the report closes with a "Next steps" pointer to
@@ -297,12 +297,21 @@ with the way out:
297
297
  type: where to revoke, the steps, and what revocation actually does at
298
298
  that vendor. `residoo explain --list` shows the whole catalogue.
299
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.
300
303
  Every finding carries a stable fingerprint (derived only from
301
304
  already-redacted material, so the ledger can never leak), shown in the
302
- report and in `--json`. Acknowledged findings are reported as such on the
303
- next scan instead of re-alarming forever. The ledger lives at
304
- `~/.residoo/rotations.json`: residoo's own file, written atomically, ack
305
- 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.
306
315
  - **Order matters, and the report says so when it does.** The ChainDrop
307
316
  campaign (Aug 2026) shipped a token monitor that fires an attacker payload
308
317
  the moment the stolen GitHub token is revoked. When one scan finds both
@@ -310,9 +319,9 @@ with the way out:
310
319
  the planted persistence first and rotate second, because "rotate
311
320
  everything now" advice can itself trigger the damage.
312
321
 
313
- Acks change what the report says, never what CI does: `--fail-on-find`
314
- fails on every finding, acknowledged or not, unless you explicitly pass
315
- `--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).
316
325
 
317
326
  ## CI and pre-commit
318
327
 
@@ -329,7 +338,7 @@ As a GitHub Action (this repository doubles as a composite action):
329
338
  ```yaml
330
339
  steps:
331
340
  - uses: actions/checkout@v4
332
- - uses: dandovdub/residoo@v0.3.9
341
+ - uses: dandovdub/residoo@v0.4.0
333
342
  ```
334
343
 
335
344
  As a pre-commit hook:
@@ -337,7 +346,7 @@ As a pre-commit hook:
337
346
  ```yaml
338
347
  repos:
339
348
  - repo: https://github.com/dandovdub/residoo
340
- rev: v0.3.9
349
+ rev: v0.4.0
341
350
  hooks:
342
351
  - id: residoo
343
352
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.3.9",
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
@@ -8,7 +8,7 @@ const { scan, emptyResult } = require("./scan");
8
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;
@@ -467,9 +512,11 @@ async function main(argv) {
467
512
  sources = availableSources();
468
513
  }
469
514
 
470
- // loadAcks degrades to {} (loudly, on stderr) if the state file is corrupt,
471
- // 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.
472
518
  const acks = loadAcks();
519
+ const dismissed = loadDismissed();
473
520
 
474
521
  if (sources.length === 0) {
475
522
  const empty = emptyResult();
@@ -482,7 +529,7 @@ async function main(argv) {
482
529
  // A --json caller (CI, a script piping into jq) must always get valid JSON
483
530
  // on stdout, even on the "nothing to scan" path — a plain-text message on
484
531
  // stderr with exit 0 silently breaks that contract.
485
- process.stdout.write(renderJson(empty, integrity, renderRotation([], acks)) + "\n");
532
+ process.stdout.write(renderJson(empty, integrity, renderRotation([], acks, dismissed)) + "\n");
486
533
  } else {
487
534
  process.stderr.write(
488
535
  "No known transcript sources found on this machine.\n" +
@@ -499,7 +546,7 @@ async function main(argv) {
499
546
  const result = await scan({ sources, includeNoisy, includeSuppressed, onProgress: progress.onProgress });
500
547
  progress.stop();
501
548
  const integrity = wantsIntegrity ? runIntegrity() : null;
502
- const rotation = renderRotation(result.findings, acks);
549
+ const rotation = renderRotation(result.findings, acks, dismissed);
503
550
  process.stdout.write((wantsSarif
504
551
  ? renderSarif(result)
505
552
  : wantsJson
package/src/report.js CHANGED
@@ -143,9 +143,13 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
143
143
  // raw values, while these entries dedupe fingerprints (which include the
144
144
  // basename, so one value in two differently-named files is two rotations to
145
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(", ");
146
150
  push(paint(c.bold, "Rotation:") +
147
151
  ` ${counts.pending} of ${counts.distinct} rotation${counts.distinct === 1 ? "" : "s"} pending` +
148
- (counts.acked > 0 ? ` (${counts.acked} acknowledged)` : ""));
152
+ (resolvedNote ? ` (${resolvedNote})` : ""));
149
153
  if (showAdvisory) {
150
154
  const wrapped = wrapText(ROTATION_ORDER_ADVISORY, 72, " ");
151
155
  push(` ${paint(c.red + c.bold, "⚠ " + wrapped[0])}`);
@@ -155,11 +159,17 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
155
159
  // dump. Everything elided here is in --json in full.
156
160
  const MAX_SHOWN = 12;
157
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
+ };
158
167
  for (const e of shown) {
159
- const tag = e.status === "pending" ? paint(c.yellow, "pending") : paint(c.green, "acked ");
160
- push(` ${tag} ${e.fingerprint} ${e.label}`);
168
+ push(` ${STATUS_TAG[e.status]} ${e.fingerprint} ${e.label}`);
161
169
  if (e.status === "acked") {
162
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)`));
163
173
  } else {
164
174
  const g = e.guidance;
165
175
  const where = g.rotateUrl ? `rotate: ${g.rotateUrl}` : `where: ${g.consolePath}`;
@@ -169,7 +179,7 @@ function renderRotationSection(rotation, { noColor = false, showAdvisory = false
169
179
  if (entries.length > shown.length) {
170
180
  push(paint(c.dim, ` … and ${entries.length - shown.length} more; see --json for the full list`));
171
181
  }
172
- 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>`));
173
183
  return lines.join("\n");
174
184
  }
175
185
 
@@ -287,6 +297,31 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
287
297
  push(paint(c.dim, ` ${filesScanned} files scanned (${(bytesScanned / 1024 / 1024).toFixed(1)} MB) · sources: ${sourcesScanned.join(", ")}`));
288
298
  push(paint(c.dim, ` oldest match ~${ageDays(oldest)}d old · most recent ~${ageDays(newest)}d old`) + suppressedNote);
289
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
+ }
290
325
  push();
291
326
 
292
327
  const sorted = [...byRule.entries()].sort((a, b) => b[1].items.length - a[1].items.length);
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
  };