residoo 0.17.0 → 0.18.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
@@ -72,14 +72,24 @@ observed, not just documented. All 8, not just the closest one:
72
72
  | tool | distinct credentials found | precision | egress during the scan |
73
73
  |---|---|---|---|
74
74
  | **residoo** | **45/45 (100%)** | **100%** | **none-observed** |
75
- | agentsweep | 33/42 (79%) | 100% | none-observed |
75
+ | agentsweep | 33/42 (79%) | 89% | none-observed |
76
76
  | gitleaks | 32/45 (71%) | 100% | none-observed |
77
- | betterleaks | 32/45 (71%) | 100% | none-observed |
77
+ | betterleaks | 32/45 (71%) | 95% | none-observed |
78
78
  | whatileaked | 28/42 (67%) | 100% | none-observed |
79
- | kingfisher | 29/45 (64%) | 100% | attempts calls in default mode |
79
+ | kingfisher | 29/45 (64%) | 97% | attempts calls in default mode |
80
80
  | trufflehog | 29/45 (64%) | 97% | attempts calls in default mode |
81
81
  | detect-secrets | 25/45 (56%) | 2% | attempts calls in default mode |
82
82
 
83
+ Precision here counts a flagged vendor-documented example key (a real,
84
+ deliberate suppress-placeholder in the corpus) as a false positive, the
85
+ stricter of the two measures `bench/RESULTS.md` reports throughout —
86
+ several tools above score better on the looser "excluding suppress flags"
87
+ measure (e.g. agentsweep and betterleaks both reach 100% there), but this
88
+ is the one that matches what a user actually experiences: a tool that
89
+ flags AWS's own published example key on every run trains people to
90
+ ignore its output. residoo and gitleaks are the only two tools that hit
91
+ 100% either way.
92
+
83
93
  "none-observed" is a measured result, not a default assumption: every run
84
94
  sits under a live proxy trap and process-tree polling, and a deliberate
85
95
  canary connection is fired and confirmed caught *before* each real
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.17.0",
3
+ "version": "0.18.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
@@ -2,6 +2,7 @@
2
2
 
3
3
  const path = require("path");
4
4
  const fs = require("fs");
5
+ const os = require("os");
5
6
  const crypto = require("crypto");
6
7
  const { availableSources, ALL_SOURCES } = require("./sources");
7
8
  const { scan, emptyResult } = require("./scan");
@@ -13,7 +14,7 @@ const {
13
14
  const { startWatch, isTailable } = require("./watch");
14
15
  const { startMcpServer } = require("./mcp");
15
16
  const { buildTools } = require("./mcpTools");
16
- const { runGuard: runGuardEngine } = require("./guard");
17
+ const { runGuard: runGuardEngine, buildHookConfig } = require("./guard");
17
18
 
18
19
  /**
19
20
  * A source is unavailable for the ordinary reason (not installed — nothing
@@ -183,6 +184,15 @@ Watch:
183
184
  never to one already seen
184
185
  --include-noisy, --include-suppressed, --include-pii, --no-color
185
186
  same meaning as scan
187
+ --no-notify skip the OS desktop notification watch fires for
188
+ each genuinely new finding (macOS via osascript,
189
+ Linux via notify-send if installed; no built-in
190
+ mechanism on Windows -- disclosed, not attempted).
191
+ On by default in human-readable mode: watch's own
192
+ purpose is alerting you, and a background process
193
+ nobody is watching a terminal for needs more than
194
+ a printed line. Never fires for a re-exposure of
195
+ something already seen, and never in --json mode.
186
196
  Ctrl+C stops cleanly and prints a session summary (skipped with --json,
187
197
  where the same information is one final NDJSON event).
188
198
 
@@ -292,6 +302,19 @@ Guard:
292
302
  "hooks":[{"type":"command",
293
303
  "command":"residoo guard"}]}]}}
294
304
 
305
+ residoo guard --print-config
306
+ print that same merged JSON to stdout instead of
307
+ hand-writing it -- reads your existing
308
+ ~/.claude/settings.json if present, adds
309
+ whatever residoo guard hooks are missing (never
310
+ duplicates one already there), and prints the
311
+ result. Writes NOTHING to disk: residoo never
312
+ writes any file but its own rotation ledger and
313
+ an explicit --seal vault, so save it yourself:
314
+ residoo guard --print-config > ~/.claude/settings.json
315
+ --project targets ./.claude/settings.json (the
316
+ repo-local config) instead of the home-level one.
317
+
295
318
  Rotation:
296
319
  residoo explain <rule-id> full rotation runbook for one detection rule
297
320
  (where to revoke, steps, what revocation does)
@@ -688,6 +711,7 @@ async function runWatch(args) {
688
711
  const verify = args.includes("--verify");
689
712
  const noColor = args.includes("--no-color");
690
713
  const includePii = args.includes("--include-pii");
714
+ const noNotify = args.includes("--no-notify");
691
715
 
692
716
  let intervalSeconds = 5;
693
717
  const intervalArg = argValue(args, "--interval");
@@ -713,7 +737,7 @@ async function runWatch(args) {
713
737
 
714
738
  const { promise, stop } = startWatch({
715
739
  sources,
716
- options: { includeNoisy, includeSuppressed, verify, noColor, includePii, json: wantsJson, pollMs: intervalSeconds * 1000 },
740
+ options: { includeNoisy, includeSuppressed, verify, noColor, includePii, noNotify, json: wantsJson, pollMs: intervalSeconds * 1000 },
717
741
  });
718
742
 
719
743
  const printFinalSummary = (stats) => {
@@ -743,6 +767,57 @@ async function runWatch(args) {
743
767
  return 0;
744
768
  }
745
769
 
770
+ /**
771
+ * `residoo guard --print-config`: print the merged .claude/settings.json
772
+ * a user would need to register all three guard hooks, without ever
773
+ * writing it -- see guard.js's own buildHookConfig docstring for why this
774
+ * is print-only rather than an auto-installer. Reads the existing file
775
+ * (home-level by default, `--project` for the repo-local one) if present,
776
+ * merges in whatever hook groups are missing, and writes the result to
777
+ * STDOUT ONLY -- every instructional line goes to stderr, so
778
+ * `residoo guard --print-config > ~/.claude/settings.json` redirects
779
+ * exactly the JSON and nothing else.
780
+ */
781
+ function runGuardPrintConfig(args) {
782
+ const wantsProject = args.includes("--project");
783
+ const targetPath = wantsProject
784
+ ? path.join(process.cwd(), ".claude", "settings.json")
785
+ : path.join(os.homedir(), ".claude", "settings.json");
786
+
787
+ let existing = {};
788
+ let existedAlready = false;
789
+ try {
790
+ const raw = fs.readFileSync(targetPath, "utf-8");
791
+ existedAlready = true;
792
+ try { existing = JSON.parse(raw); }
793
+ catch {
794
+ process.stderr.write(
795
+ `residoo guard --print-config: ${targetPath} exists but is not valid JSON. ` +
796
+ `Fix it first -- printing a merge against unparseable JSON would risk losing whatever is already there.\n`
797
+ );
798
+ return 1;
799
+ }
800
+ } catch (err) {
801
+ if (!(err && err.code === "ENOENT")) {
802
+ process.stderr.write(`residoo guard --print-config: could not read ${targetPath}: ${err.message}\n`);
803
+ return 1;
804
+ }
805
+ }
806
+
807
+ const merged = buildHookConfig(existing);
808
+ process.stderr.write(
809
+ (existedAlready
810
+ ? `residoo guard --print-config: merged residoo's three hooks into your existing ${targetPath} below.\n`
811
+ : `residoo guard --print-config: ${targetPath} doesn't exist yet -- here's a new one with residoo's three hooks.\n`) +
812
+ `Nothing was written to disk -- residoo never writes any file but its own rotation ledger and an explicit ` +
813
+ `--seal vault. Save this yourself, e.g.:\n` +
814
+ ` residoo guard --print-config${wantsProject ? " --project" : ""} > ${targetPath}\n` +
815
+ (wantsProject ? "" : "(add --project to target ./.claude/settings.json instead of the home-level one)\n")
816
+ );
817
+ process.stdout.write(JSON.stringify(merged, null, 2) + "\n");
818
+ return 0;
819
+ }
820
+
746
821
  /**
747
822
  * `residoo mcp`: run residoo as an MCP server over stdio. See src/mcp.js
748
823
  * for the protocol engine and src/mcpTools.js for the tool catalog; this
@@ -925,7 +1000,10 @@ async function main(argv) {
925
1000
  if (cmd === "watch") return runWatch(args);
926
1001
  if (cmd === "mcp") return runMcp(args);
927
1002
  if (cmd === "cred") return runCred(args);
928
- if (cmd === "guard") return runGuardEngine();
1003
+ if (cmd === "guard") {
1004
+ if (args.includes("--print-config")) return runGuardPrintConfig(args.slice(1));
1005
+ return runGuardEngine();
1006
+ }
929
1007
  if (cmd !== "scan") {
930
1008
  process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
931
1009
  return 2;
package/src/guard.js CHANGED
@@ -70,6 +70,56 @@
70
70
  const { PATTERNS, redact } = require("./patterns");
71
71
  const { VENDOR_EXAMPLE_VALUES, zeroEntropyTail } = require("./scan");
72
72
 
73
+ const GUARD_COMMAND = "residoo guard";
74
+
75
+ // matcher: null means Claude Code's own hook-config schema omits the field
76
+ // entirely for an event with no matcher support (UserPromptSubmit) --
77
+ // distinct from an empty string, which would mean "match everything" for
78
+ // an event that DOES support matchers.
79
+ const HOOK_EVENT_SPECS = [
80
+ { event: "PreToolUse", matcher: "Bash|Read" },
81
+ { event: "UserPromptSubmit", matcher: null },
82
+ { event: "PostToolUse", matcher: "Bash" },
83
+ ];
84
+
85
+ /**
86
+ * Pure function: given a parsed Claude Code settings.json object (or `{}`
87
+ * for a fresh one), returns a NEW object with a "residoo guard" hook group
88
+ * appended to each of PreToolUse/UserPromptSubmit/PostToolUse's hook
89
+ * arrays -- unless a "residoo guard" command already exists ANYWHERE in
90
+ * that event's groups, in which case that event is left untouched
91
+ * (idempotent: running this against its own prior output changes
92
+ * nothing). Never mutates `existing`; every other key, every other tool's
93
+ * hook, every unrelated setting is carried through byte-for-byte.
94
+ *
95
+ * This function only COMPUTES a value -- it never touches a file, on
96
+ * purpose. CONTRIBUTING.md's own hard rule (rule 3) names
97
+ * `~/.residoo/rotations.json` as "the only file residoo ever writes
98
+ * outside an explicit --seal ... nothing else may claim this carve-out."
99
+ * An auto-installing `guard --install` that edited `.claude/settings.json`
100
+ * directly would violate that rule outright, so the CLI wraps this in
101
+ * `--print-config` instead (see cli.js's runGuardPrintConfig): print the
102
+ * merged JSON to stdout, let the human decide whether and where to save it.
103
+ */
104
+ function buildHookConfig(existing) {
105
+ const settings = existing && typeof existing === "object" && !Array.isArray(existing)
106
+ ? JSON.parse(JSON.stringify(existing)) : {};
107
+ if (!settings.hooks || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) settings.hooks = {};
108
+
109
+ for (const { event, matcher } of HOOK_EVENT_SPECS) {
110
+ const groups = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
111
+ const alreadyPresent = groups.some((g) =>
112
+ g && typeof g === "object" && Array.isArray(g.hooks) &&
113
+ g.hooks.some((h) => h && typeof h === "object" && h.type === "command" && h.command === GUARD_COMMAND));
114
+ if (alreadyPresent) { settings.hooks[event] = groups; continue; }
115
+ const newGroup = matcher
116
+ ? { matcher, hooks: [{ type: "command", command: GUARD_COMMAND }] }
117
+ : { hooks: [{ type: "command", command: GUARD_COMMAND }] };
118
+ settings.hooks[event] = [...groups, newGroup];
119
+ }
120
+ return settings;
121
+ }
122
+
73
123
  // A matched path fragment must be preceded by a path separator or the start
74
124
  // of the string, and followed by either the end of the string (the common
75
125
  // case for Read's file_path) or a shell metacharacter/whitespace (the case
@@ -402,5 +452,5 @@ async function runGuard({ input = process.stdin, output = process.stdout, errOut
402
452
 
403
453
  module.exports = {
404
454
  evaluateToolInput, matchSensitivePath, evaluatePromptText, evaluatePostToolUse,
405
- runGuard, SENSITIVE_PATH_PATTERNS,
455
+ runGuard, buildHookConfig, SENSITIVE_PATH_PATTERNS,
406
456
  };
package/src/notify.js ADDED
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+
3
+ const cp = require("child_process");
4
+
5
+ /**
6
+ * Best-effort OS desktop notification for `residoo watch`. macOS via
7
+ * `osascript` (always present, no new dependency -- the same shell-out
8
+ * precedent `keychain.js`'s `security` and `ocr.js`'s `tesseract` already
9
+ * set), Linux via `notify-send` (commonly present on a desktop session,
10
+ * NOT guaranteed -- `watch` also runs on headless/server machines with no
11
+ * notification daemon at all).
12
+ *
13
+ * Windows: no built-in, dependency-free mechanism was found that doesn't
14
+ * either need an external module (BurntToast) or pop a blocking, modal
15
+ * MessageBox in front of a background process -- a disclosed scope limit,
16
+ * not silently assumed covered, the same posture `keychain.js` already
17
+ * takes for its own Windows refusal.
18
+ *
19
+ * Decoration, never the report itself: `watch`'s own `emit()` already
20
+ * writes every finding to stdout/stderr before this is ever called, so a
21
+ * missing binary, no display server, or a spawn error here must never
22
+ * throw, block, or affect the caller in any way -- it can only make an
23
+ * already-reported finding easier to notice sooner.
24
+ *
25
+ * `cp.spawn` (not destructured at module load) so a test can monkey-patch
26
+ * `require("child_process").spawn` directly and restore it after, without
27
+ * this module needing its own injectable-dependency parameter.
28
+ */
29
+ function notifyDesktop(title, message) {
30
+ try {
31
+ if (process.platform === "darwin") {
32
+ // osascript's -e takes one AppleScript source string; spawn (no
33
+ // shell:true) passes it as a single argv entry, so there is no shell
34
+ // to inject into -- but the string still has to be valid AppleScript
35
+ // source, so its own quote/backslash characters need escaping or a
36
+ // stray one just breaks the script into a harmless no-op.
37
+ const esc = (s) => String(s).replace(/[\\"]/g, "\\$&");
38
+ const script = `display notification "${esc(message)}" with title "${esc(title)}"`;
39
+ const child = cp.spawn("osascript", ["-e", script], { stdio: "ignore" });
40
+ child.on("error", () => {}); // binary missing or spawn failed: never throw
41
+ child.unref();
42
+ } else if (process.platform === "linux") {
43
+ const child = cp.spawn("notify-send", [String(title), String(message)], { stdio: "ignore" });
44
+ child.on("error", () => {});
45
+ child.unref();
46
+ }
47
+ // Windows and anything else: no-op, disclosed above, not attempted.
48
+ } catch {
49
+ // Never let a notification failure affect the caller.
50
+ }
51
+ }
52
+
53
+ module.exports = { notifyDesktop };
package/src/watch.js CHANGED
@@ -6,6 +6,7 @@ const crypto = require("crypto");
6
6
  const { scan } = require("./scan");
7
7
  const { guidanceFor, fingerprintFinding, loadAcks, loadDismissed, statePath } = require("./rotation");
8
8
  const { c, makePaint } = require("./color");
9
+ const { notifyDesktop } = require("./notify");
9
10
 
10
11
  /**
11
12
  * `residoo watch`: continuous, near-real-time scanning of the same
@@ -563,7 +564,7 @@ function reloadLedgerIfChanged(prev) {
563
564
  * backstop and nothing riding on top of it. `options.fsWatch` is accepted
564
565
  * and ignored, reserved for when a future version adds real hints.
565
566
  */
566
- function startWatch({ sources, options = {}, out = process.stdout, errOut = process.stderr } = {}) {
567
+ function startWatch({ sources, options = {}, out = process.stdout, errOut = process.stderr, notify = notifyDesktop } = {}) {
567
568
  const paint = makePaint(options.noColor, out);
568
569
  const tracked = new Map();
569
570
  const seen = new Map();
@@ -582,6 +583,17 @@ function startWatch({ sources, options = {}, out = process.stdout, errOut = proc
582
583
  }
583
584
  const line = renderHumanLine(event, paint);
584
585
  if (line !== null) out.write(line + "\n");
586
+ // Desktop notification is decoration on top of the line just written
587
+ // above, never a substitute for it -- JSON mode is for programmatic
588
+ // consumption (a pipe, a log shipper), not a human sitting in front of
589
+ // the terminal, so it's excluded the same way `options.noColor` only
590
+ // applies to the human-line path. Only a genuinely NEW finding notifies
591
+ // -- a `reexposure` (the same secret seen again) or a `watch-error`
592
+ // would otherwise turn a quiet, healthy watch into a notification
593
+ // spamming machine.
594
+ if (event.type === "finding" && !options.json && !options.noNotify) {
595
+ notify("residoo: new secret found", `${event.label} in ${event.relFile}:${event.line} (${event.preview})`);
596
+ }
585
597
  }
586
598
 
587
599
  async function tick() {