residoo 0.17.0 → 0.19.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
@@ -165,6 +175,13 @@ gap, none catches every case, and all are re-includable with
165
175
  certainty — true of every tool in this category, including the well-
166
176
  established ones.
167
177
 
178
+ - **No mobile app.** Researched, not assumed: residoo's file-scanning
179
+ approach cannot port to stock iOS under Apple's own sandboxing model,
180
+ and every alternative mechanism checked (a keyboard extension, a local
181
+ VPN content filter) has a specific, disqualifying problem. See
182
+ [docs/platform-scope.md](docs/platform-scope.md) for the full technical
183
+ verdict and what would change it.
184
+
168
185
  ## Install
169
186
 
170
187
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.17.0",
3
+ "version": "0.19.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
 
@@ -207,7 +217,15 @@ MCP:
207
217
  Cred:
208
218
  residoo cred set <name> --env <ENV_VAR_NAME> [--env <ENV_VAR_NAME_2> ...]
209
219
  store a live credential in the OS keychain
210
- (macOS/Linux only), one or more env-var names
220
+ (macOS/Linux only -- genuinely unsupported on
221
+ Windows, not just undone yet: this needs a NAMED
222
+ store you can write once and read back later,
223
+ and Windows has none reachable without an extra
224
+ dependency. Different from --seal --keychain
225
+ below, which Windows DOES support via DPAPI --
226
+ that one only ever needs to wrap/unwrap a key
227
+ living inside its own vault directory, never a
228
+ named lookup), one or more env-var names
211
229
  mapped to hidden-typed values. Interactive TTY
212
230
  only, no scripted entry: a live credential is
213
231
  more sensitive than a vault passphrase and
@@ -292,6 +310,19 @@ Guard:
292
310
  "hooks":[{"type":"command",
293
311
  "command":"residoo guard"}]}]}}
294
312
 
313
+ residoo guard --print-config
314
+ print that same merged JSON to stdout instead of
315
+ hand-writing it -- reads your existing
316
+ ~/.claude/settings.json if present, adds
317
+ whatever residoo guard hooks are missing (never
318
+ duplicates one already there), and prints the
319
+ result. Writes NOTHING to disk: residoo never
320
+ writes any file but its own rotation ledger and
321
+ an explicit --seal vault, so save it yourself:
322
+ residoo guard --print-config > ~/.claude/settings.json
323
+ --project targets ./.claude/settings.json (the
324
+ repo-local config) instead of the home-level one.
325
+
295
326
  Rotation:
296
327
  residoo explain <rule-id> full rotation runbook for one detection rule
297
328
  (where to revoke, steps, what revocation does)
@@ -315,10 +346,19 @@ Seal options (used with scan):
315
346
  in the OS keychain instead of a typed passphrase.
316
347
  Nothing to remember, and the key's strength no longer
317
348
  depends on passphrase choice. macOS today; Linux when
318
- secret-tool (libsecret) is installed. TRADEOFF: a
319
- keychain-backed vault lives on THIS machine/account
349
+ secret-tool (libsecret) is installed; Windows via
350
+ DPAPI (a different mechanism, not a Windows Credential
351
+ Manager entry -- cmdkey.exe is confirmed write-only,
352
+ it can never read a stored password back out). TRADEOFF:
353
+ a keychain-backed vault lives on THIS machine/account
320
354
  only, unlike a passphrase, it is not portable to
321
- another machine.
355
+ another machine. On Windows specifically, the DPAPI-
356
+ wrapped key travels INSIDE the vault directory itself
357
+ (there's no separate OS-level store to put it in,
358
+ unlike macOS/Linux) -- still undecryptable without
359
+ being logged in as the same Windows user on the same
360
+ machine, but a real, disclosed difference from macOS/
361
+ Linux's fully separate keychain entry.
322
362
  --vault-dir <dir> where to create the vault (default: ./residoo-vault-<stamp>)
323
363
  --upload-cloudroam ALSO upload the sealed vault to CloudRoam. One of two
324
364
  opt-in features that touch the network (--verify
@@ -379,31 +419,70 @@ async function getPassphrase({ confirmNew }) {
379
419
  * sealFindings/deriveKey path a typed passphrase would use — scrypt on a
380
420
  * full 256-bit-entropy input is harmless extra defense, and reusing that
381
421
  * already-tested path means no change to sealcrypto.js/sealvault.js at all.
422
+ *
423
+ * `winKeyBlob` is Windows' equivalent of `vaultId`: there is no OS-level
424
+ * named credential store on Windows (see keychain.js's own
425
+ * wrapVaultKeyWindows docstring for exactly why, and why this is a
426
+ * DIFFERENT mechanism from `vaultId`'s macOS/Linux keychain.store(), not a
427
+ * Windows branch inside it), so instead of a lookup id, the caller gets
428
+ * back the actual DPAPI-wrapped key blob to persist itself once the vault
429
+ * directory exists — the one place on Windows with a rule-compliant
430
+ * reason to write it.
382
431
  */
383
432
  async function resolveSealSecret(args) {
384
433
  if (!args.includes("--keychain")) {
385
- return { passphrase: await getPassphrase({ confirmNew: true }), vaultId: null };
434
+ return { passphrase: await getPassphrase({ confirmNew: true }), vaultId: null, winKeyBlob: null };
386
435
  }
436
+ const passphrase = crypto.randomBytes(32).toString("base64");
387
437
  const keychain = require("./keychain");
438
+ if (process.platform === "win32") {
439
+ if (!keychain.isVaultKeySupported()) throw new Error("--keychain: DPAPI is not available on this system.");
440
+ return { passphrase, vaultId: null, winKeyBlob: keychain.wrapVaultKeyWindows(passphrase) };
441
+ }
388
442
  if (!keychain.isSupported()) throw new Error(`--keychain: ${keychain.unsupportedReason()}`);
389
443
  const vaultId = crypto.randomUUID();
390
- const passphrase = crypto.randomBytes(32).toString("base64");
391
444
  keychain.store(vaultId, passphrase);
392
- return { passphrase, vaultId };
445
+ return { passphrase, vaultId, winKeyBlob: null };
393
446
  }
394
447
 
395
- /** The unsealing secret for `unseal`: a keychain-retrieved key, or a typed passphrase. */
448
+ /**
449
+ * The unsealing secret for `unseal`: a keychain-retrieved key, or a typed
450
+ * passphrase. Checked by which MARKER FILE is actually present in the
451
+ * vault directory, not by the current machine's platform — a vault sealed
452
+ * on Windows carries `.keychain-key-win`, one sealed on macOS/Linux
453
+ * carries `.keychain-id`, and trying to unseal one on the wrong kind of
454
+ * machine should say so clearly rather than silently mis-routing.
455
+ */
396
456
  async function resolveUnsealSecret(args, vaultDir) {
397
457
  if (!args.includes("--keychain")) return getPassphrase({ confirmNew: false });
398
458
  const keychain = require("./keychain");
399
- if (!keychain.isSupported()) throw new Error(`--keychain: ${keychain.unsupportedReason()}`);
459
+ const winKeyPath = path.join(vaultDir, ".keychain-key-win");
400
460
  const idPath = path.join(vaultDir, ".keychain-id");
461
+
462
+ if (fs.existsSync(winKeyPath)) {
463
+ if (!keychain.isVaultKeySupported()) {
464
+ throw new Error(
465
+ `This vault was sealed with --keychain on Windows (DPAPI-backed). DPAPI keys are tied to that ` +
466
+ `Windows user account and cannot be unwrapped on ${process.platform} — unseal it on that same Windows machine.`
467
+ );
468
+ }
469
+ try {
470
+ return keychain.unwrapVaultKeyWindows(fs.readFileSync(winKeyPath, "utf-8").trim());
471
+ } catch {
472
+ throw new Error(
473
+ "Could not decrypt this vault's key via Windows DPAPI. It may have been sealed under a different " +
474
+ "Windows user account or moved to a different machine — a keychain-backed vault is not portable."
475
+ );
476
+ }
477
+ }
478
+
401
479
  if (!fs.existsSync(idPath)) {
402
480
  throw new Error(
403
- `No .keychain-id marker in ${vaultDir}: this vault was not sealed with --keychain, ` +
481
+ `No .keychain-id or .keychain-key-win marker in ${vaultDir}: this vault was not sealed with --keychain, ` +
404
482
  `or the marker file was moved separately from the vault. Try unsealing without --keychain.`
405
483
  );
406
484
  }
485
+ if (!keychain.isSupported()) throw new Error(`--keychain: ${keychain.unsupportedReason()}`);
407
486
  const vaultId = fs.readFileSync(idPath, "utf-8").trim();
408
487
  try {
409
488
  return keychain.retrieve(vaultId);
@@ -444,7 +523,7 @@ async function runSeal(result, args) {
444
523
 
445
524
  const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
446
525
  const vaultDir = argValue(args, "--vault-dir") || path.resolve(`residoo-vault-${stamp}`);
447
- const { passphrase, vaultId } = await resolveSealSecret(args);
526
+ const { passphrase, vaultId, winKeyBlob } = await resolveSealSecret(args);
448
527
 
449
528
  process.stdout.write(`\nSealing ${filesWithFindings.length} file(s) with findings into ${vaultDir}\n`);
450
529
  const { entries } = await sealFindings({
@@ -456,6 +535,13 @@ async function runSeal(result, args) {
456
535
  // keychain lookup, never the key itself and never anything about what the
457
536
  // vault contains.
458
537
  if (vaultId) fs.writeFileSync(path.join(vaultDir, ".keychain-id"), vaultId, { mode: 0o600 });
538
+ // Windows' equivalent marker -- unlike .keychain-id, this DOES hold
539
+ // sensitive material (the DPAPI-wrapped key itself, not a lookup id),
540
+ // which is exactly the disclosed security-property trade-off
541
+ // keychain.js's wrapVaultKeyWindows docstring names: on Windows the
542
+ // wrapped key travels with the vault, decryptable only by the same
543
+ // Windows user account, rather than living in a fully separate store.
544
+ if (winKeyBlob) fs.writeFileSync(path.join(vaultDir, ".keychain-key-win"), winKeyBlob, { mode: 0o600 });
459
545
  const totalPlain = entries.reduce((s, e) => s + e.plainBytes, 0);
460
546
  const totalSealed = entries.reduce((s, e) => s + e.sealedBytes, 0);
461
547
  process.stdout.write(
@@ -688,6 +774,7 @@ async function runWatch(args) {
688
774
  const verify = args.includes("--verify");
689
775
  const noColor = args.includes("--no-color");
690
776
  const includePii = args.includes("--include-pii");
777
+ const noNotify = args.includes("--no-notify");
691
778
 
692
779
  let intervalSeconds = 5;
693
780
  const intervalArg = argValue(args, "--interval");
@@ -713,7 +800,7 @@ async function runWatch(args) {
713
800
 
714
801
  const { promise, stop } = startWatch({
715
802
  sources,
716
- options: { includeNoisy, includeSuppressed, verify, noColor, includePii, json: wantsJson, pollMs: intervalSeconds * 1000 },
803
+ options: { includeNoisy, includeSuppressed, verify, noColor, includePii, noNotify, json: wantsJson, pollMs: intervalSeconds * 1000 },
717
804
  });
718
805
 
719
806
  const printFinalSummary = (stats) => {
@@ -743,6 +830,57 @@ async function runWatch(args) {
743
830
  return 0;
744
831
  }
745
832
 
833
+ /**
834
+ * `residoo guard --print-config`: print the merged .claude/settings.json
835
+ * a user would need to register all three guard hooks, without ever
836
+ * writing it -- see guard.js's own buildHookConfig docstring for why this
837
+ * is print-only rather than an auto-installer. Reads the existing file
838
+ * (home-level by default, `--project` for the repo-local one) if present,
839
+ * merges in whatever hook groups are missing, and writes the result to
840
+ * STDOUT ONLY -- every instructional line goes to stderr, so
841
+ * `residoo guard --print-config > ~/.claude/settings.json` redirects
842
+ * exactly the JSON and nothing else.
843
+ */
844
+ function runGuardPrintConfig(args) {
845
+ const wantsProject = args.includes("--project");
846
+ const targetPath = wantsProject
847
+ ? path.join(process.cwd(), ".claude", "settings.json")
848
+ : path.join(os.homedir(), ".claude", "settings.json");
849
+
850
+ let existing = {};
851
+ let existedAlready = false;
852
+ try {
853
+ const raw = fs.readFileSync(targetPath, "utf-8");
854
+ existedAlready = true;
855
+ try { existing = JSON.parse(raw); }
856
+ catch {
857
+ process.stderr.write(
858
+ `residoo guard --print-config: ${targetPath} exists but is not valid JSON. ` +
859
+ `Fix it first -- printing a merge against unparseable JSON would risk losing whatever is already there.\n`
860
+ );
861
+ return 1;
862
+ }
863
+ } catch (err) {
864
+ if (!(err && err.code === "ENOENT")) {
865
+ process.stderr.write(`residoo guard --print-config: could not read ${targetPath}: ${err.message}\n`);
866
+ return 1;
867
+ }
868
+ }
869
+
870
+ const merged = buildHookConfig(existing);
871
+ process.stderr.write(
872
+ (existedAlready
873
+ ? `residoo guard --print-config: merged residoo's three hooks into your existing ${targetPath} below.\n`
874
+ : `residoo guard --print-config: ${targetPath} doesn't exist yet -- here's a new one with residoo's three hooks.\n`) +
875
+ `Nothing was written to disk -- residoo never writes any file but its own rotation ledger and an explicit ` +
876
+ `--seal vault. Save this yourself, e.g.:\n` +
877
+ ` residoo guard --print-config${wantsProject ? " --project" : ""} > ${targetPath}\n` +
878
+ (wantsProject ? "" : "(add --project to target ./.claude/settings.json instead of the home-level one)\n")
879
+ );
880
+ process.stdout.write(JSON.stringify(merged, null, 2) + "\n");
881
+ return 0;
882
+ }
883
+
746
884
  /**
747
885
  * `residoo mcp`: run residoo as an MCP server over stdio. See src/mcp.js
748
886
  * for the protocol engine and src/mcpTools.js for the tool catalog; this
@@ -925,7 +1063,10 @@ async function main(argv) {
925
1063
  if (cmd === "watch") return runWatch(args);
926
1064
  if (cmd === "mcp") return runMcp(args);
927
1065
  if (cmd === "cred") return runCred(args);
928
- if (cmd === "guard") return runGuardEngine();
1066
+ if (cmd === "guard") {
1067
+ if (args.includes("--print-config")) return runGuardPrintConfig(args.slice(1));
1068
+ return runGuardEngine();
1069
+ }
929
1070
  if (cmd !== "scan") {
930
1071
  process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
931
1072
  return 2;
@@ -1110,4 +1251,10 @@ async function main(argv) {
1110
1251
  return failOnFind && (secretGate || integrityWarnCount(integrity) > 0) ? 1 : 0;
1111
1252
  }
1112
1253
 
1113
- module.exports = { main };
1254
+ // resolveSealSecret/resolveUnsealSecret are exported alongside `main`
1255
+ // specifically so tests/smoke.js can exercise their Windows (DPAPI) branch
1256
+ // directly: a spawned subprocess (this file's usual CLI-testing precedent)
1257
+ // always runs on the REAL host platform, so it can never exercise
1258
+ // win32-only logic on a non-Windows build machine -- calling these two
1259
+ // in-process, with process.platform mocked, is the only way to.
1260
+ module.exports = { main, resolveSealSecret, resolveUnsealSecret };
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/integrity.js CHANGED
@@ -3,6 +3,12 @@
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
5
  const os = require("os");
6
+ // Not destructured: kept as `cp.execFileSync(...)` at every call site so a
7
+ // test can monkey-patch `require("child_process").execFileSync` (the same
8
+ // shared module object) the way tests/smoke.js's notify.js tests already
9
+ // do -- a destructured `const { execFileSync } = ...` would copy the
10
+ // reference at import time and never see a later patch.
11
+ const cp = require("child_process");
6
12
  const { PATTERNS, redact } = require("./patterns");
7
13
 
8
14
  /**
@@ -677,7 +683,59 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
677
683
  }
678
684
  }
679
685
 
680
- // ---- 5. credential-vault file permissions (HOME only, POSIX only) ------
686
+ // Windows NTFS ACL check for one credential-vault file, the win32
687
+ // counterpart to the POSIX `stat.mode` check below -- Node's fs.Stats.mode
688
+ // on Windows does not reflect NTFS ACLs at all, so this shells out to
689
+ // PowerShell's Get-Acl instead, the same "invoke the OS's own tool"
690
+ // pattern keychain.js already uses for macOS's `security` and Linux's
691
+ // `secret-tool`. Verified against Microsoft's own Get-Acl documentation
692
+ // (learn.microsoft.com/powershell/module/microsoft.powershell.security/get-acl)
693
+ // and cross-checked against independent write-ups, not live-tested
694
+ // against a real Windows install -- disclosed the same way copilot-cli.js's
695
+ // own header discloses "corroborated but unverified against a real
696
+ // install" for a source built without one available. icacls was
697
+ // considered and rejected: research found it has no `/findsid` switch
698
+ // and its text output is not reliably parseable, unlike Get-Acl's typed
699
+ // object model.
700
+ //
701
+ // Every field is projected through an explicit PSCustomObject (never the
702
+ // raw IdentityReference/FileSystemRights/AccessControlType objects) so
703
+ // JSON serialization is forced to plain strings regardless of how those
704
+ // .NET types would otherwise serialize -- and the result is wrapped in an
705
+ // array via `-InputObject` (not piped) specifically because ConvertTo-Json
706
+ // collapses a single-item collection into a bare object rather than a
707
+ // one-element array when piped, a well-known PowerShell gotcha that would
708
+ // otherwise break parsing on the (common) case of a file with exactly one
709
+ // relevant ACE.
710
+ //
711
+ // English-locale principal names only (Everyone, BUILTIN\Users, NT
712
+ // AUTHORITY\Authenticated Users) -- a disclosed, not silent, gap: a
713
+ // non-English Windows install localizes these names (e.g. "Jeder" for
714
+ // Everyone in German) and would not match here, the same kind of named
715
+ // scope limit ibanValid's per-country-length gap and the OCR module's
716
+ // English-only wordlist already carry elsewhere in this project.
717
+ function checkWindowsCredentialAcl(file) {
718
+ const escaped = file.replace(/'/g, "''");
719
+ const script =
720
+ "$ErrorActionPreference='Stop'; " +
721
+ `$acl = Get-Acl -LiteralPath '${escaped}'; ` +
722
+ "$rules = @($acl.Access | ForEach-Object { [PSCustomObject]@{ " +
723
+ "Identity = $_.IdentityReference.Value; Rights = $_.FileSystemRights.ToString(); Type = $_.AccessControlType.ToString() } }); " +
724
+ "ConvertTo-Json -InputObject $rules -Compress";
725
+ const out = cp.execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf-8" });
726
+ let rules;
727
+ try { rules = JSON.parse(out); } catch { return null; }
728
+ if (!Array.isArray(rules)) rules = [rules];
729
+ const BROAD_PRINCIPAL = /^(everyone|builtin\\users|nt authority\\authenticated users|users)$/i;
730
+ const READ_CAPABLE_RIGHTS = /fullcontrol|modify|read/i;
731
+ const hits = rules.filter((r) =>
732
+ r && typeof r.Identity === "string" && typeof r.Type === "string" && typeof r.Rights === "string" &&
733
+ r.Type === "Allow" && BROAD_PRINCIPAL.test(r.Identity.trim()) && READ_CAPABLE_RIGHTS.test(r.Rights));
734
+ if (hits.length === 0) return { tooOpen: false };
735
+ return { tooOpen: true, detail: hits.map((h) => `${h.Identity} (${h.Rights})`).join(", ") };
736
+ }
737
+
738
+ // ---- 5. credential-vault file permissions (HOME only, POSIX and win32) -
681
739
  // GitGuardian's "State of Secrets Sprawl 2026" (blog.gitguardian.com,
682
740
  // published 2026-03-17) found 24,008 unique secrets in MCP-related config
683
741
  // files on public GitHub, 8.8% of them still live -- these files
@@ -700,13 +758,14 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
700
758
  // naive backup restore, or a shared-volume container mount can each
701
759
  // silently widen a file's mode without the tool that wrote it ever
702
760
  // knowing, the same way SSH itself checks id_rsa's permissions rather
703
- // than trusting whoever created it. Windows is skipped entirely, not
704
- // approximated: Node's fs.Stats.mode on Windows does not reflect NTFS
705
- // ACLs, so a POSIX-style bit check there would be meaningless rather than
706
- // merely imprecise. Project mode is skipped too -- none of these are ever
761
+ // than trusting whoever created it. Windows gets its own NTFS-ACL-based
762
+ // check (checkWindowsCredentialAcl above), not a POSIX-bit approximation
763
+ // -- Node's fs.Stats.mode on Windows does not reflect NTFS ACLs at all,
764
+ // so pretending otherwise would be meaningless, not merely imprecise.
765
+ // Project mode is skipped on every platform -- none of these are ever
707
766
  // project-scoped files by any vendor's own design, so there is no
708
767
  // project-relative equivalent to check.
709
- if (!projectMode && process.platform !== "win32") {
768
+ if (!projectMode) {
710
769
  const geminiDir = process.env.GEMINI_CLI_HOME
711
770
  ? path.join(process.env.GEMINI_CLI_HOME, ".gemini")
712
771
  : path.join(home, ".gemini");
@@ -739,6 +798,24 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
739
798
  }
740
799
  continue;
741
800
  }
801
+
802
+ if (process.platform === "win32") {
803
+ let acl = null;
804
+ try { acl = checkWindowsCredentialAcl(file); } catch { acl = null; }
805
+ if (!acl) {
806
+ mark(file, "unreadable");
807
+ add("warn", "unreadable-config", file, "credential file exists but its NTFS permissions could not be checked (Get-Acl failed): unverified, not clean");
808
+ continue;
809
+ }
810
+ mark(file, "checked");
811
+ if (acl.tooOpen) {
812
+ const shown = display(file);
813
+ add("warn", "insecure-credential-permissions", file,
814
+ `${note}; NTFS permissions grant ${acl.detail} access to a live credential on this machine. Fix: right-click "${shown}" > Properties > Security > Advanced, and remove any group other than your own account and the system/administrators.`);
815
+ }
816
+ continue;
817
+ }
818
+
742
819
  mark(file, "checked");
743
820
  const openBits = stat.mode & 0o077;
744
821
  if (openBits !== 0) {
package/src/keychain.js CHANGED
@@ -1,6 +1,11 @@
1
1
  "use strict";
2
2
 
3
- const { execFileSync } = require("child_process");
3
+ // Not destructured: kept as `cp.execFileSync(...)` at every call site so a
4
+ // test can monkey-patch `require("child_process").execFileSync` (the same
5
+ // shared module object) the way tests/smoke.js's Windows-path tests do for
6
+ // both this file and integrity.js -- a destructured `const { execFileSync
7
+ // } = ...` would copy the reference at import time and never see a patch.
8
+ const cp = require("child_process");
4
9
 
5
10
  /**
6
11
  * OS-native secure credential storage for `scan --seal --keychain` and
@@ -64,7 +69,7 @@ function isSupported() {
64
69
  if (process.platform === "darwin") return true;
65
70
  if (process.platform === "linux") {
66
71
  try {
67
- execFileSync("which", ["secret-tool"], { stdio: "ignore" });
72
+ cp.execFileSync("which", ["secret-tool"], { stdio: "ignore" });
68
73
  return true;
69
74
  } catch {
70
75
  return false;
@@ -78,9 +83,108 @@ function unsupportedReason() {
78
83
  if (process.platform === "linux") {
79
84
  return "secret-tool (libsecret) is not installed. Install it (e.g. \"apt install libsecret-tools\" or \"dnf install libsecret\") or omit --keychain to use a passphrase instead.";
80
85
  }
86
+ if (process.platform === "win32") {
87
+ // Specifically about THIS by-name store/retrieve/remove API (residoo
88
+ // cred's only use of it) -- `--seal --keychain` works on Windows via a
89
+ // different, vault-relative mechanism (wrapVaultKeyWindows/
90
+ // unwrapVaultKeyWindows below), so this message must not read as "no
91
+ // --keychain support at all on Windows," which would be wrong.
92
+ return "residoo cred needs a named credential store, and Windows has none reachable without an extra dependency " +
93
+ "(cmdkey.exe can store a credential but never reads its password back -- confirmed against Microsoft's own docs). " +
94
+ "--seal --keychain works on Windows through a different mechanism; this specific store is what's unavailable.";
95
+ }
81
96
  return `--keychain is not supported on ${process.platform} yet. Omit --keychain to use a passphrase instead.`;
82
97
  }
83
98
 
99
+ /**
100
+ * True when wrapVaultKeyWindows/unwrapVaultKeyWindows (below) can run --
101
+ * Windows only, since DPAPI is a Windows-specific API. Deliberately
102
+ * SEPARATE from isSupported() above: that function is about the by-name
103
+ * store()/retrieve()/remove() API (false on Windows -- no OS-level named
104
+ * credential store is reachable there without an extra dependency, since
105
+ * cmdkey.exe is confirmed write/list-only, never returning a stored
106
+ * password). This one is about the vault-relative wrap/unwrap pair, which
107
+ * exists only because `--seal --keychain` has a legitimate place (the
108
+ * vault directory itself) to put a DPAPI-wrapped blob -- `residoo cred`
109
+ * has no such place and does not use this.
110
+ */
111
+ function isVaultKeySupported() {
112
+ return process.platform === "win32";
113
+ }
114
+
115
+ /**
116
+ * Windows-only DPAPI (Data Protection API) wrap/unwrap for `--seal
117
+ * --keychain`'s vault key, via a PowerShell shell-out -- the same
118
+ * "invoke the OS's own tool" pattern as macOS's `security` and Linux's
119
+ * `secret-tool` above, but a materially different SHAPE, and why these
120
+ * two functions exist separately from store()/retrieve() rather than as a
121
+ * Windows branch inside them.
122
+ *
123
+ * Windows has no OS-level "store this under a name, fetch it back by that
124
+ * name later" service the way macOS Keychain/secret-tool do. Verified
125
+ * directly, not assumed: `cmdkey.exe` (Windows Credential Manager's own
126
+ * CLI) can WRITE a generic credential but Microsoft's own documentation
127
+ * states plainly "Passwords are not displayed after they're stored," and
128
+ * `/list` only ever surfaces target names and usernames -- cmdkey is
129
+ * write/list-only, and cannot serve a store-then-retrieve flow at all.
130
+ * DPAPI (`System.Security.Cryptography.ProtectedData`, confirmed reachable
131
+ * from stock PowerShell 5.1 via `Add-Type -AssemblyName System.Security`
132
+ * with zero extra installs -- learn.microsoft.com/dotnet/standard/security/
133
+ * how-to-use-data-protection) is the real built-in alternative, but it is
134
+ * a stateless encrypt/decrypt PRIMITIVE, not a named registry: something
135
+ * still has to decide where the encrypted bytes are persisted.
136
+ *
137
+ * That is why wrapVaultKeyWindows only WRAPS a secret and hands the
138
+ * encrypted blob straight back to its caller -- this module never decides
139
+ * where it lives. `--seal --keychain` (cli.js's resolveSealSecret/
140
+ * resolveUnsealSecret) is the one caller with a rule-compliant place to
141
+ * put it: the vault directory itself, already within `--seal`'s own
142
+ * carve-out under CONTRIBUTING.md's hard rule (residoo writes nothing
143
+ * outside `~/.residoo/rotations.json` and an explicit `--seal`). `residoo
144
+ * cred` stores a long-lived credential with no vault of its own, so there
145
+ * is no rule-compliant place to write a DPAPI blob for it -- it remains
146
+ * genuinely unsupported on Windows (isSupported() above, unchanged), not
147
+ * worked around by squeezing it through this pair too.
148
+ *
149
+ * Disclosed security-property difference, not glossed over: on macOS/
150
+ * Linux, the key lives in a genuinely separate OS-managed store, entirely
151
+ * absent from the vault directory -- copying just the vault gets an
152
+ * attacker nothing at all. On Windows, the wrapped blob travels WITH the
153
+ * vault directory (inside it), so copying the whole vault also copies the
154
+ * blob. DPAPI's CurrentUser-scope encryption still means that blob is
155
+ * only decryptable by the same Windows user account on the same Windows
156
+ * installation -- not portable, the same end-user guarantee --keychain
157
+ * already documents -- but a threat model where an attacker can read the
158
+ * vault directory's files without being able to run code as that same
159
+ * user gets less protection here than macOS/Linux's physically-separate
160
+ * store provides. Verified against Microsoft's own DPAPI documentation;
161
+ * not live-tested against a real Windows install.
162
+ */
163
+ function wrapVaultKeyWindows(secret) {
164
+ if (process.platform !== "win32") throw new Error("wrapVaultKeyWindows is Windows-only.");
165
+ const escaped = String(secret).replace(/'/g, "''");
166
+ const script =
167
+ "$ErrorActionPreference='Stop'; " +
168
+ "Add-Type -AssemblyName System.Security; " +
169
+ `$bytes = [System.Text.Encoding]::UTF8.GetBytes('${escaped}'); ` +
170
+ "$enc = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser); " +
171
+ "[Convert]::ToBase64String($enc)";
172
+ return cp.execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf-8" }).trim();
173
+ }
174
+
175
+ /** The other half of wrapVaultKeyWindows -- see its docstring for the full design. */
176
+ function unwrapVaultKeyWindows(blob) {
177
+ if (process.platform !== "win32") throw new Error("unwrapVaultKeyWindows is Windows-only.");
178
+ const escaped = String(blob).replace(/'/g, "''");
179
+ const script =
180
+ "$ErrorActionPreference='Stop'; " +
181
+ "Add-Type -AssemblyName System.Security; " +
182
+ `$enc = [Convert]::FromBase64String('${escaped}'); ` +
183
+ "$dec = [Security.Cryptography.ProtectedData]::Unprotect($enc, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser); " +
184
+ "[System.Text.Encoding]::UTF8.GetString($dec)";
185
+ return cp.execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf-8" }).trim();
186
+ }
187
+
84
188
  /**
85
189
  * Store `secret` (a string) under `account` in the OS keychain, for later
86
190
  * retrieve(account). `keychainFile`, macOS only, is an escape hatch used
@@ -97,13 +201,13 @@ function store(account, secret, keychainFile, service = SERVICE) {
97
201
  const kf = keychainFile || testKeychainFile();
98
202
  const args = ["add-generic-password", "-a", account, "-s", service, "-w", secret, "-U"];
99
203
  if (kf) args.push(kf);
100
- execFileSync("security", args, { stdio: "ignore" });
204
+ cp.execFileSync("security", args, { stdio: "ignore" });
101
205
  return;
102
206
  }
103
207
  if (process.platform === "linux") {
104
208
  // secret-tool reads the secret from stdin, never a CLI argument, so it
105
209
  // never appears in a process listing or shell history.
106
- execFileSync("secret-tool", [
210
+ cp.execFileSync("secret-tool", [
107
211
  "store", "--label", "residoo sealed vault key", "service", service, "account", account,
108
212
  ], { input: secret, stdio: ["pipe", "ignore", "ignore"] });
109
213
  return;
@@ -120,10 +224,10 @@ function retrieve(account, keychainFile, service = SERVICE) {
120
224
  const kf = keychainFile || testKeychainFile();
121
225
  const args = ["find-generic-password", "-a", account, "-s", service, "-w"];
122
226
  if (kf) args.push(kf);
123
- return execFileSync("security", args, { encoding: "utf8" }).trim();
227
+ return cp.execFileSync("security", args, { encoding: "utf8" }).trim();
124
228
  }
125
229
  if (process.platform === "linux") {
126
- return execFileSync("secret-tool", [
230
+ return cp.execFileSync("secret-tool", [
127
231
  "lookup", "service", service, "account", account,
128
232
  ], { encoding: "utf8" }).trim();
129
233
  }
@@ -142,14 +246,17 @@ function remove(account, keychainFile, service = SERVICE) {
142
246
  const kf = keychainFile || testKeychainFile();
143
247
  const args = ["delete-generic-password", "-a", account, "-s", service];
144
248
  if (kf) args.push(kf);
145
- execFileSync("security", args, { stdio: "ignore" });
249
+ cp.execFileSync("security", args, { stdio: "ignore" });
146
250
  return;
147
251
  }
148
252
  if (process.platform === "linux") {
149
- execFileSync("secret-tool", ["clear", "service", service, "account", account], { stdio: "ignore" });
253
+ cp.execFileSync("secret-tool", ["clear", "service", service, "account", account], { stdio: "ignore" });
150
254
  return;
151
255
  }
152
256
  throw new Error(unsupportedReason());
153
257
  }
154
258
 
155
- module.exports = { isSupported, unsupportedReason, store, retrieve, remove, CRED_SERVICE };
259
+ module.exports = {
260
+ isSupported, unsupportedReason, store, retrieve, remove, CRED_SERVICE,
261
+ isVaultKeySupported, wrapVaultKeyWindows, unwrapVaultKeyWindows,
262
+ };
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() {