residoo 0.16.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.16.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)",
@@ -0,0 +1,270 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The canonical BIP-39 English wordlist (2048 words), verbatim from the
5
+ * spec's own reference file: github.com/bitcoin/bips, bip-0039/english.txt
6
+ * (fetched directly, not retyped from memory -- transcription errors here
7
+ * would silently break every checksum computation in src/pii.js). Order
8
+ * matters: word N's position in this array IS its 11-bit index per the
9
+ * spec, so this list must never be re-sorted or edited.
10
+ */
11
+ const BIP39_ENGLISH_WORDLIST = [
12
+ "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract",
13
+ "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid",
14
+ "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual",
15
+ "adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance",
16
+ "advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent",
17
+ "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album",
18
+ "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone",
19
+ "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among",
20
+ "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry",
21
+ "animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique",
22
+ "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april",
23
+ "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor",
24
+ "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact",
25
+ "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume",
26
+ "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction",
27
+ "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado",
28
+ "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis",
29
+ "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball",
30
+ "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base",
31
+ "basic", "basket", "battle", "beach", "bean", "beauty", "because", "become",
32
+ "beef", "before", "begin", "behave", "behind", "believe", "below", "belt",
33
+ "bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle",
34
+ "bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black",
35
+ "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood",
36
+ "blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body",
37
+ "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring",
38
+ "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain",
39
+ "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief",
40
+ "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother",
41
+ "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb",
42
+ "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus",
43
+ "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable",
44
+ "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can",
45
+ "canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable",
46
+ "capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry",
47
+ "cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog",
48
+ "catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling",
49
+ "celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk",
50
+ "champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap",
51
+ "check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child",
52
+ "chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar",
53
+ "cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify",
54
+ "claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff",
55
+ "climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud",
56
+ "clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut",
57
+ "code", "coffee", "coil", "coin", "collect", "color", "column", "combine",
58
+ "come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm",
59
+ "congress", "connect", "consider", "control", "convince", "cook", "cool", "copper",
60
+ "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch",
61
+ "country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle",
62
+ "craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream",
63
+ "credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop",
64
+ "cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch",
65
+ "crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious",
66
+ "current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad",
67
+ "damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn",
68
+ "day", "deal", "debate", "debris", "decade", "december", "decide", "decline",
69
+ "decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay",
70
+ "deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend",
71
+ "deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk",
72
+ "despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram",
73
+ "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital",
74
+ "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover",
75
+ "disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide",
76
+ "divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain",
77
+ "donate", "donkey", "donor", "door", "dose", "double", "dove", "draft",
78
+ "dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill",
79
+ "drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb",
80
+ "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager",
81
+ "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo",
82
+ "ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight",
83
+ "either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator",
84
+ "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ",
85
+ "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy",
86
+ "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough",
87
+ "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode",
88
+ "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt",
89
+ "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil",
90
+ "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude",
91
+ "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit",
92
+ "exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend",
93
+ "extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint",
94
+ "faith", "fall", "false", "fame", "family", "famous", "fan", "fancy",
95
+ "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault",
96
+ "favorite", "feature", "february", "federal", "fee", "feed", "feel", "female",
97
+ "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field",
98
+ "figure", "file", "film", "filter", "final", "find", "fine", "finger",
99
+ "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness",
100
+ "fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight",
101
+ "flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly",
102
+ "foam", "focus", "fog", "foil", "fold", "follow", "food", "foot",
103
+ "force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil",
104
+ "foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend",
105
+ "fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel",
106
+ "fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy",
107
+ "gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment",
108
+ "gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius",
109
+ "genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle",
110
+ "ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass",
111
+ "glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue",
112
+ "goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip",
113
+ "govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass",
114
+ "gravity", "great", "green", "grid", "grief", "grit", "grocery", "group",
115
+ "grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun",
116
+ "gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy",
117
+ "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard",
118
+ "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet",
119
+ "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip",
120
+ "hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow",
121
+ "home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital",
122
+ "host", "hotel", "hour", "hover", "hub", "huge", "human", "humble",
123
+ "humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband",
124
+ "hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill",
125
+ "illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose",
126
+ "improve", "impulse", "inch", "include", "income", "increase", "index", "indicate",
127
+ "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial",
128
+ "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane",
129
+ "insect", "inside", "inspire", "install", "intact", "interest", "into", "invest",
130
+ "invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory",
131
+ "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel",
132
+ "job", "join", "joke", "journey", "joy", "judge", "juice", "jump",
133
+ "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup",
134
+ "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit",
135
+ "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know",
136
+ "lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language",
137
+ "laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law",
138
+ "lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave",
139
+ "lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend",
140
+ "length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty",
141
+ "library", "license", "life", "lift", "light", "like", "limb", "limit",
142
+ "link", "lion", "liquid", "list", "little", "live", "lizard", "load",
143
+ "loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop",
144
+ "lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber",
145
+ "lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet",
146
+ "maid", "mail", "main", "major", "make", "mammal", "man", "manage",
147
+ "mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin",
148
+ "marine", "market", "marriage", "mask", "mass", "master", "match", "material",
149
+ "math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure",
150
+ "meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory",
151
+ "mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message",
152
+ "metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind",
153
+ "minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake",
154
+ "mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment",
155
+ "monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning",
156
+ "mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie",
157
+ "much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music",
158
+ "must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin",
159
+ "narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative",
160
+ "neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral",
161
+ "never", "news", "next", "nice", "night", "noble", "noise", "nominee",
162
+ "noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice",
163
+ "novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey",
164
+ "object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean",
165
+ "october", "odor", "off", "offer", "office", "often", "oil", "okay",
166
+ "old", "olive", "olympic", "omit", "once", "one", "onion", "online",
167
+ "only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit",
168
+ "orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich",
169
+ "other", "outdoor", "outer", "output", "outside", "oval", "oven", "over",
170
+ "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page",
171
+ "pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper",
172
+ "parade", "parent", "park", "parrot", "party", "pass", "patch", "path",
173
+ "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut",
174
+ "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper",
175
+ "perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical",
176
+ "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot",
177
+ "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet",
178
+ "plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge",
179
+ "poem", "poet", "point", "polar", "pole", "police", "pond", "pony",
180
+ "pool", "popular", "portion", "position", "possible", "post", "potato", "pottery",
181
+ "poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare",
182
+ "present", "pretty", "prevent", "price", "pride", "primary", "print", "priority",
183
+ "prison", "private", "prize", "problem", "process", "produce", "profit", "program",
184
+ "project", "promote", "proof", "property", "prosper", "protect", "proud", "provide",
185
+ "public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil",
186
+ "puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle",
187
+ "pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz",
188
+ "quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail",
189
+ "rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid",
190
+ "rare", "rate", "rather", "raven", "raw", "razor", "ready", "real",
191
+ "reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle",
192
+ "reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject",
193
+ "relax", "release", "relief", "rely", "remain", "remember", "remind", "remove",
194
+ "render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report",
195
+ "require", "rescue", "resemble", "resist", "resource", "response", "result", "retire",
196
+ "retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib",
197
+ "ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid",
198
+ "ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road",
199
+ "roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room",
200
+ "rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude",
201
+ "rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness",
202
+ "safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same",
203
+ "sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say",
204
+ "scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science",
205
+ "scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea",
206
+ "search", "season", "seat", "second", "secret", "section", "security", "seed",
207
+ "seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence",
208
+ "series", "service", "session", "settle", "setup", "seven", "shadow", "shaft",
209
+ "shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine",
210
+ "ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder",
211
+ "shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side",
212
+ "siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar",
213
+ "simple", "since", "sing", "siren", "sister", "situate", "six", "size",
214
+ "skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab",
215
+ "slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan",
216
+ "slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth",
217
+ "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social",
218
+ "sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve",
219
+ "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup",
220
+ "source", "south", "space", "spare", "spatial", "spawn", "speak", "special",
221
+ "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin",
222
+ "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray",
223
+ "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium",
224
+ "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay",
225
+ "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting",
226
+ "stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street",
227
+ "strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject",
228
+ "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest",
229
+ "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme",
230
+ "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain",
231
+ "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim",
232
+ "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table",
233
+ "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target",
234
+ "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten",
235
+ "tenant", "tennis", "tent", "term", "test", "text", "thank", "that",
236
+ "theme", "then", "theory", "there", "they", "thing", "this", "thought",
237
+ "three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger",
238
+ "tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title",
239
+ "toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token",
240
+ "tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top",
241
+ "topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist",
242
+ "toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic",
243
+ "train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree",
244
+ "trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy",
245
+ "trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try",
246
+ "tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle",
247
+ "twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical",
248
+ "ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo",
249
+ "unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown",
250
+ "unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon",
251
+ "upper", "upset", "urban", "urge", "usage", "use", "used", "useful",
252
+ "useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley",
253
+ "valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle",
254
+ "velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very",
255
+ "vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view",
256
+ "village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual",
257
+ "vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote",
258
+ "voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want",
259
+ "warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave",
260
+ "way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding",
261
+ "weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat",
262
+ "wheel", "when", "where", "whip", "whisper", "wide", "width", "wife",
263
+ "wild", "will", "win", "window", "wine", "wing", "wink", "winner",
264
+ "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman",
265
+ "wonder", "wood", "wool", "word", "work", "world", "worry", "worth",
266
+ "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year",
267
+ "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo",
268
+ ];
269
+
270
+ module.exports = { BIP39_ENGLISH_WORDLIST };
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
@@ -150,13 +151,19 @@ Scan options:
150
151
  similar characters (0/O, Y/*) can be misread,
151
152
  which breaks an exact-format match, so this is
152
153
  best-effort additional coverage, not a guarantee.
153
- --include-pii also scan for PII: US Social Security Numbers
154
- (dashed format only), credit card numbers
155
- (Luhn-validated), and IBANs (checksum-validated).
156
- A different RISK CATEGORY, not a lower confidence
157
- bar -- residoo is deliberately credentials-only
158
- by default. Deliberately excludes bare email/
159
- phone (too common in ordinary text to meet this
154
+ --include-pii also scan for PII and adjacent secrets: US Social
155
+ Security Numbers (dashed format only), credit
156
+ card numbers (Luhn-validated), IBANs (checksum-
157
+ validated), and crypto wallet seed phrases
158
+ (BIP-39, SHA-256-checksum-validated -- a
159
+ CREDENTIAL, not personal data, kept in this flag
160
+ rather than a new one to avoid flag proliferation
161
+ for the same "opt in, then checksum-validate"
162
+ shape). A different RISK CATEGORY, not a lower
163
+ confidence bar -- residoo is deliberately
164
+ credentials-only by default. Deliberately
165
+ excludes bare email/phone (too common in
166
+ ordinary text to meet this
160
167
  project's own high-confidence bar even opt-in).
161
168
 
162
169
  Watch:
@@ -177,6 +184,15 @@ Watch:
177
184
  never to one already seen
178
185
  --include-noisy, --include-suppressed, --include-pii, --no-color
179
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.
180
196
  Ctrl+C stops cleanly and prints a session summary (skipped with --json,
181
197
  where the same information is one final NDJSON event).
182
198
 
@@ -227,22 +243,20 @@ Cred:
227
243
  tool, present only when RESIDOO_CRED_ALLOWED_COMMANDS is configured.
228
244
 
229
245
  Guard:
230
- residoo guard one binary, two Claude Code hooks, dispatched on
231
- the payload's own hook_event_name:
246
+ residoo guard one binary, three Claude Code hooks, dispatched
247
+ on the payload's own hook_event_name:
232
248
 
233
249
  PreToolUse blocks an obviously-sensitive file read
234
250
  (.env, id_rsa, .aws/credentials, and similar)
235
251
  before it can be written to the session
236
- transcript at all. Narrower than it sounds:
237
- Claude Code's hooks API can see a proposed Bash
238
- command or Read path before it runs, but never
239
- the command's OUTPUT, so this alone cannot catch
240
- a secret typed into a prompt or one arriving
241
- through an unrelated command's output.
242
-
243
- UserPromptSubmit closes exactly that gap: it
244
- checks the user's own typed prompt against
245
- residoo's 79 high-confidence rules (never
252
+ transcript at all. Narrower than it sounds on its
253
+ own: this only sees a proposed Bash command or
254
+ Read path before it runs, based on WHAT you're
255
+ about to touch, not what actually comes back.
256
+
257
+ UserPromptSubmit closes the "typed into a prompt"
258
+ gap: it checks the user's own typed prompt against
259
+ residoo's 84 high-confidence rules (never
246
260
  --verify, never --ocr -- this hook has no matcher
247
261
  and fires on every single prompt, so it must stay
248
262
  fast) and can block it before Claude processes it
@@ -258,17 +272,49 @@ Guard:
258
272
  consistent with this hook never blocking on
259
273
  anything it doesn't recognize.
260
274
 
275
+ PostToolUse closes the "arriving through a
276
+ command's output" gap for Bash specifically: it
277
+ scans the command's actual stdout/stderr against
278
+ the same 84 rules and, if one matches, replaces
279
+ the output with a redacted version before Claude
280
+ ever sees it (Claude Code's own updatedToolOutput
281
+ decision field). Bash only, disclosed rather than
282
+ silently partial: every other built-in tool's
283
+ exact output shape isn't documented precisely
284
+ enough to reconstruct a replacement safely, and a
285
+ wrong shape is silently ignored by Claude Code,
286
+ not reported as a failure. The command has
287
+ already run by this point -- this cannot undo a
288
+ file write or network call, only keep the raw
289
+ value out of the model's context.
290
+
261
291
  scan/watch/mcp remain the real safety net either
262
292
  way -- this is prevention on top of detection,
263
- not a replacement for it. Add both to
293
+ not a replacement for it. Add all three to
264
294
  .claude/settings.json:
265
295
  {"hooks":{
266
296
  "PreToolUse":[{"matcher":"Bash|Read",
267
297
  "hooks":[{"type":"command",
268
298
  "command":"residoo guard"}]}],
269
299
  "UserPromptSubmit":[{"hooks":[{"type":"command",
300
+ "command":"residoo guard"}]}],
301
+ "PostToolUse":[{"matcher":"Bash",
302
+ "hooks":[{"type":"command",
270
303
  "command":"residoo guard"}]}]}}
271
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
+
272
318
  Rotation:
273
319
  residoo explain <rule-id> full rotation runbook for one detection rule
274
320
  (where to revoke, steps, what revocation does)
@@ -665,6 +711,7 @@ async function runWatch(args) {
665
711
  const verify = args.includes("--verify");
666
712
  const noColor = args.includes("--no-color");
667
713
  const includePii = args.includes("--include-pii");
714
+ const noNotify = args.includes("--no-notify");
668
715
 
669
716
  let intervalSeconds = 5;
670
717
  const intervalArg = argValue(args, "--interval");
@@ -690,7 +737,7 @@ async function runWatch(args) {
690
737
 
691
738
  const { promise, stop } = startWatch({
692
739
  sources,
693
- options: { includeNoisy, includeSuppressed, verify, noColor, includePii, json: wantsJson, pollMs: intervalSeconds * 1000 },
740
+ options: { includeNoisy, includeSuppressed, verify, noColor, includePii, noNotify, json: wantsJson, pollMs: intervalSeconds * 1000 },
694
741
  });
695
742
 
696
743
  const printFinalSummary = (stats) => {
@@ -720,6 +767,57 @@ async function runWatch(args) {
720
767
  return 0;
721
768
  }
722
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
+
723
821
  /**
724
822
  * `residoo mcp`: run residoo as an MCP server over stdio. See src/mcp.js
725
823
  * for the protocol engine and src/mcpTools.js for the tool catalog; this
@@ -902,7 +1000,10 @@ async function main(argv) {
902
1000
  if (cmd === "watch") return runWatch(args);
903
1001
  if (cmd === "mcp") return runMcp(args);
904
1002
  if (cmd === "cred") return runCred(args);
905
- if (cmd === "guard") return runGuardEngine();
1003
+ if (cmd === "guard") {
1004
+ if (args.includes("--print-config")) return runGuardPrintConfig(args.slice(1));
1005
+ return runGuardEngine();
1006
+ }
906
1007
  if (cmd !== "scan") {
907
1008
  process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
908
1009
  return 2;
package/src/guard.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * `residoo guard`: two Claude Code hooks in one binary, dispatched on the
4
+ * `residoo guard`: three Claude Code hooks in one binary, dispatched on the
5
5
  * payload's own `hook_event_name` field.
6
6
  *
7
7
  * PreToolUse blocks an obviously-sensitive file read before it happens,
@@ -11,14 +11,18 @@
11
11
  * from leaking": Claude Code's hooks API gives a PreToolUse hook the
12
12
  * PROPOSED tool input (a Bash command string, a Read file_path) before the
13
13
  * tool runs, and lets it deny the call outright -- but it never sees the
14
- * tool's OUTPUT, and by the time a PostToolUse hook fires, that output is
15
- * already committed to the transcript and can no longer be redacted. There
16
- * is no documented hook mechanism for "let the read happen, but strip the
17
- * secret out of what the model sees." So this can only block INPUT that
18
- * matches a known-sensitive file path pattern (.env, id_rsa, .aws/credentials,
19
- * and similar) -- it cannot catch a secret typed directly into a prompt, a
20
- * secret arriving in the output of an otherwise-unremarkable command
21
- * (curl, a build log), or any file path this pattern list does not name.
14
+ * tool's OUTPUT. So on its own, this can only block INPUT that matches a
15
+ * known-sensitive file path pattern (.env, id_rsa, .aws/credentials, and
16
+ * similar) -- it cannot catch a secret typed directly into a prompt, or one
17
+ * arriving in the output of an otherwise-unremarkable command (curl, `git
18
+ * log`, a build log), or any file path this pattern list does not name.
19
+ * UserPromptSubmit (below) closes the first gap; PostToolUse (below) closes
20
+ * the second, corrected from an earlier version of this comment that
21
+ * called it uncatchable -- Claude Code's own `updatedToolOutput` decision
22
+ * field (confirmed directly against code.claude.com/docs/en/hooks, not
23
+ * assumed) can in fact replace what the model sees, discovered during a
24
+ * later competitive-research pass after GitGuardian's ggshield was found to
25
+ * already scan tool output at this exact stage.
22
26
  *
23
27
  * UserPromptSubmit closes exactly the "typed directly into a prompt" gap
24
28
  * named above: Claude Code's own docs (code.claude.com/docs/en/hooks,
@@ -66,6 +70,56 @@
66
70
  const { PATTERNS, redact } = require("./patterns");
67
71
  const { VENDOR_EXAMPLE_VALUES, zeroEntropyTail } = require("./scan");
68
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
+
69
123
  // A matched path fragment must be preceded by a path separator or the start
70
124
  // of the string, and followed by either the end of the string (the common
71
125
  // case for Read's file_path) or a shell metacharacter/whitespace (the case
@@ -204,8 +258,86 @@ function evaluatePromptText(promptText) {
204
258
  }
205
259
 
206
260
  /**
207
- * Writes one structured audit line to stderr for a block decision --
208
- * CONTRIBUTING.md's own hard rule (rule 3) names `~/.residoo/rotations.json`
261
+ * Redacts every high-confidence match in `text`, tracking the first REAL
262
+ * (non-suppressed) hit for the audit trail/preview. Same suppression rule
263
+ * as evaluatePromptText (a documented vendor-example key or an obvious
264
+ * zero-entropy placeholder is never redacted) and the same rule set
265
+ * (PROMPT_GUARD_RULES) -- one bar for everything this module scans text
266
+ * content with, whether that content is a typed prompt or a command's
267
+ * output. `String.prototype.replace` with each rule's own global regex
268
+ * redacts every occurrence in one pass per rule; a value already inside a
269
+ * PREVIOUS rule's redaction (unlikely given how distinct these vendor
270
+ * prefixes are, but not impossible) is walked past unchanged rather than
271
+ * double-redacted, since only literal ORIGINAL text can still match a
272
+ * pattern from patterns.js.
273
+ */
274
+ function redactSecretsInText(text) {
275
+ if (typeof text !== "string" || !text) return { text: typeof text === "string" ? text : "", hit: null };
276
+ let result = text;
277
+ let hit = null;
278
+ for (const rule of PROMPT_GUARD_RULES) {
279
+ rule.re.lastIndex = 0;
280
+ result = result.replace(rule.re, (match) => {
281
+ if (VENDOR_EXAMPLE_VALUES.has(match) || zeroEntropyTail(match)) return match;
282
+ if (!hit) hit = { label: rule.label, preview: redact(match) };
283
+ return redact(match);
284
+ });
285
+ }
286
+ return { text: result, hit };
287
+ }
288
+
289
+ /**
290
+ * Pure decision function: given a PostToolUse hook payload's tool_name and
291
+ * tool_response, decide whether to redact. No I/O, fully unit-testable.
292
+ *
293
+ * Bash only, and stated why rather than left implicit: Claude Code's own
294
+ * docs (code.claude.com/docs/en/hooks, "PostToolUse decision control",
295
+ * fetched directly) state `updatedToolOutput` "must match the tool's
296
+ * output shape" and "a value that doesn't match the tool's output schema
297
+ * is ignored and the original output is used" -- a SILENT no-op, not an
298
+ * error residoo could detect and report. Bash's shape ({stdout, stderr,
299
+ * interrupted, isImage}) is the one built-in tool output shape those same
300
+ * docs actually publish; every other built-in tool's tool_response shape
301
+ * is not documented at this level of precision, so reconstructing one
302
+ * would risk exactly the silent-no-op failure this project's own
303
+ * discipline exists to avoid. A stated, narrow scope limit, not full
304
+ * "every tool's output" coverage -- and still a real, new defense layer:
305
+ * PreToolUse's matchSensitivePath blocks based on WHAT you're about to
306
+ * read (a path), this blocks based on WHAT ACTUALLY CAME BACK (content),
307
+ * catching a secret in `git log`, `env`, a build log, or any other Bash
308
+ * output PreToolUse's path-based check was never going to see coming.
309
+ *
310
+ * The tool has ALREADY run by the time this fires (Claude Code's own docs,
311
+ * same section) -- this cannot undo the read/write/network call the
312
+ * command made, and does not try to. It only keeps the raw value out of
313
+ * the model's context (and so out of the transcript this project's own
314
+ * `scan` exists to check), a narrower guarantee than PreToolUse's denial,
315
+ * disclosed as such rather than oversold.
316
+ */
317
+ function evaluatePostToolUse(toolName, toolResponse) {
318
+ if (toolName !== "Bash" || !toolResponse || typeof toolResponse !== "object") {
319
+ return { act: false, label: null, preview: null, updatedToolOutput: null };
320
+ }
321
+ const stdout = redactSecretsInText(toolResponse.stdout);
322
+ const stderr = redactSecretsInText(toolResponse.stderr);
323
+ const hit = stdout.hit || stderr.hit;
324
+ if (!hit) return { act: false, label: null, preview: null, updatedToolOutput: null };
325
+ return {
326
+ act: true,
327
+ label: hit.label,
328
+ preview: hit.preview,
329
+ updatedToolOutput: {
330
+ stdout: stdout.text,
331
+ stderr: stderr.text,
332
+ interrupted: !!toolResponse.interrupted,
333
+ isImage: !!toolResponse.isImage,
334
+ },
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Writes one structured audit line to stderr for a block OR redact decision
340
+ * -- CONTRIBUTING.md's own hard rule (rule 3) names `~/.residoo/rotations.json`
209
341
  * as "the only file residoo ever writes outside an explicit --seal...
210
342
  * nothing else may claim this carve-out," so this is NOT a new file, the
211
343
  * same choice `cred`'s own audit trail already made for the same reason
@@ -213,28 +345,45 @@ function evaluatePromptText(promptText) {
213
345
  * hook's own stderr at launch if you want it kept, same as `cred`.
214
346
  * Never the raw matched value -- `preview` is already redact()'d by the
215
347
  * caller (rule 4: no raw value in any log line, ever), and PreToolUse
216
- * decisions carry no value at all, only a path-pattern label.
348
+ * decisions carry no value at all, only a path-pattern label. `decision`
349
+ * defaults to "block" (every call site before PostToolUse existed) --
350
+ * PostToolUse passes "redact" since nothing is actually blocked there, the
351
+ * tool already ran; the log should say what really happened.
217
352
  */
218
- function logAuditLine(errOutput, { event, label, preview, sessionId, cwd }) {
353
+ function logAuditLine(errOutput, { event, label, preview, sessionId, cwd, decision = "block" }) {
219
354
  try {
220
355
  errOutput.write(JSON.stringify({
221
- ts: new Date().toISOString(), tool: "residoo guard", event, decision: "block",
356
+ ts: new Date().toISOString(), tool: "residoo guard", event, decision,
222
357
  label, ...(preview ? { preview } : {}), sessionId: sessionId || null, cwd: cwd || null,
223
358
  }) + "\n");
224
359
  } catch { /* stderr write failing is never a reason to fail the hook decision itself */ }
225
360
  }
226
361
 
227
362
  /**
228
- * Reads one PreToolUse OR UserPromptSubmit hook payload from `input`
229
- * (default stdin) -- distinguished by the payload's own `hook_event_name`
230
- * common field, a single binary handling both the way Claude Code's own
231
- * hook registration allows -- decides, and writes the hook's own JSON
232
- * response protocol to `output` (default stdout) -- exit code is the
233
- * caller's job (bin/residoo.js), this returns the intended process exit
234
- * code instead of calling process.exit itself, matching every other run*
235
- * function in cli.js. Every BLOCK decision also gets one structured line
236
- * on `errOutput` (default stderr) -- see logAuditLine's own docstring for
237
- * why stderr, never a file.
363
+ * Reads one PreToolUse, UserPromptSubmit, OR PostToolUse hook payload from
364
+ * `input` (default stdin) -- distinguished by the payload's own
365
+ * `hook_event_name` common field, a single binary handling all three the
366
+ * way Claude Code's own hook registration allows -- decides, and writes
367
+ * the hook's own JSON response protocol to `output` (default stdout) --
368
+ * exit code is the caller's job (bin/residoo.js), this returns the
369
+ * intended process exit code instead of calling process.exit itself,
370
+ * matching every other run* function in cli.js. Every BLOCK or REDACT
371
+ * decision also gets one structured line on `errOutput` (default stderr)
372
+ * -- see logAuditLine's own docstring for why stderr, never a file.
373
+ *
374
+ * The final `hook_event_name !== "PreToolUse"` guard fixes a real latent
375
+ * bug, not a hypothetical one: before PostToolUse existed here, anything
376
+ * OTHER than UserPromptSubmit fell through unconditionally to
377
+ * evaluateToolInput -- harmless while only PreToolUse and UserPromptSubmit
378
+ * were ever registered, but a PostToolUse payload also carries a
379
+ * `tool_input` field (Claude Code's own docs confirm both `tool_input` and
380
+ * `tool_response` are present), so without this guard a PostToolUse event
381
+ * would have been silently re-evaluated as a PreToolUse decision and could
382
+ * have emitted a `permissionDecision: "deny"` response for a tool call
383
+ * that had already finished executing -- a response Claude Code has no
384
+ * defined behavior for. Explicit is safer than "everything else falls
385
+ * through," the same fail-safe posture this whole module already commits
386
+ * to elsewhere.
238
387
  */
239
388
  async function runGuard({ input = process.stdin, output = process.stdout, errOutput = process.stderr } = {}) {
240
389
  const chunks = [];
@@ -266,6 +415,24 @@ async function runGuard({ input = process.stdin, output = process.stdout, errOut
266
415
  return 0;
267
416
  }
268
417
 
418
+ if (payload.hook_event_name === "PostToolUse") {
419
+ const decision = evaluatePostToolUse(payload.tool_name, payload.tool_response);
420
+ if (!decision.act) return 0;
421
+ logAuditLine(errOutput, {
422
+ event: "PostToolUse", label: decision.label, preview: decision.preview,
423
+ sessionId: payload.session_id, cwd: payload.cwd, decision: "redact",
424
+ });
425
+ output.write(JSON.stringify({
426
+ hookSpecificOutput: {
427
+ hookEventName: "PostToolUse",
428
+ updatedToolOutput: decision.updatedToolOutput,
429
+ },
430
+ }) + "\n");
431
+ return 0;
432
+ }
433
+
434
+ if (payload.hook_event_name !== "PreToolUse") return 0;
435
+
269
436
  const decision = evaluateToolInput(payload.tool_name, payload.tool_input);
270
437
  if (!decision.block) return 0;
271
438
 
@@ -283,4 +450,7 @@ async function runGuard({ input = process.stdin, output = process.stdout, errOut
283
450
  return 0;
284
451
  }
285
452
 
286
- module.exports = { evaluateToolInput, matchSensitivePath, evaluatePromptText, runGuard, SENSITIVE_PATH_PATTERNS };
453
+ module.exports = {
454
+ evaluateToolInput, matchSensitivePath, evaluatePromptText, evaluatePostToolUse,
455
+ runGuard, buildHookConfig, SENSITIVE_PATH_PATTERNS,
456
+ };
package/src/mcpTools.js CHANGED
@@ -365,7 +365,7 @@ function buildTools({ sources }) {
365
365
  projectDir: { type: "string", description: "Absolute path to a project/repo directory to scan instead of the machine-wide transcript stores (same as `residoo scan --project <dir>`). Omit for the default machine-wide scan." },
366
366
  includeNoisy: { type: "boolean", default: false, description: "Also run residoo's two low-confidence heuristic rules (generic password/secret assignments) -- catches more, false-positives more. Off by default." },
367
367
  includeSuppressed: { type: "boolean", default: false, description: "Include matches normally hidden because they look like vendor-documented example values or placeholder text. Off by default." },
368
- includePii: { type: "boolean", default: false, description: "Also scan for PII (US Social Security Numbers, Luhn-validated credit card numbers, checksum-validated IBANs) -- a different risk category from a credential, not a lower confidence bar. Off by default; residoo is deliberately credentials-only otherwise." },
368
+ includePii: { type: "boolean", default: false, description: "Also scan for PII and adjacent secrets (US Social Security Numbers, Luhn-validated credit card numbers, checksum-validated IBANs, BIP-39 checksum-validated crypto wallet seed phrases) -- a different risk category from a vendor credential, not a lower confidence bar. Off by default; residoo is deliberately credentials-only otherwise." },
369
369
  maxEntries: { type: "integer", minimum: 1, maximum: 200, default: 25, description: "Cap on distinct findings returned in full detail, pending-first. Counts in the response are always exact even when the entry list is truncated." },
370
370
  },
371
371
  required: [],
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/pii.js CHANGED
@@ -1,12 +1,16 @@
1
1
  "use strict";
2
2
 
3
+ const crypto = require("crypto");
4
+ const { BIP39_ENGLISH_WORDLIST } = require("./bip39wordlist");
5
+
3
6
  /**
4
- * Opt-in PII detection (--include-pii). Named directly by this session's
5
- * own competitive research into funded AI-DLP vendors (Strac, Cyberhaven,
6
- * Nightfall) as one of the few concrete, buildable things residoo could
7
- * adopt without becoming a hosted service -- and independently
8
- * corroborated by two direct competitors' own shipped detector lists
9
- * (DidILeak, Medusa), both of which cover PII alongside credentials.
7
+ * Opt-in PII-and-adjacent detection (--include-pii). Named directly by this
8
+ * session's own competitive research into funded AI-DLP vendors (Strac,
9
+ * Cyberhaven, Nightfall) as one of the few concrete, buildable things
10
+ * residoo could adopt without becoming a hosted service -- and
11
+ * independently corroborated by two direct competitors' own shipped
12
+ * detector lists (DidILeak, Medusa), both of which cover PII alongside
13
+ * credentials.
10
14
  *
11
15
  * Kept entirely separate from PATTERNS/NOISY_PATTERNS in patterns.js on
12
16
  * purpose: residoo's stated identity elsewhere in this project is
@@ -17,19 +21,34 @@
17
21
  * for a different reason (a different RISK CATEGORY, not a lower
18
22
  * confidence bar).
19
23
  *
20
- * Only three detectors, deliberately: DidILeak's own shipped list also
24
+ * "PII" is the name of the flag, not a perfectly accurate label for
25
+ * everything in it: a BIP-39 crypto wallet seed phrase (added after a
26
+ * competitive-feature-parity pass found AgentSweep ships seed-phrase
27
+ * detection and residoo didn't) is a CREDENTIAL, not personal data --
28
+ * arguably it belongs in the default, always-on set the way a vendor API
29
+ * key does. It lives here instead for the same architectural reason as
30
+ * the other three: no single-vendor prefix to anchor on (a seed phrase is
31
+ * 12-24 plain English words, structurally unlike every PATTERNS.js rule),
32
+ * and a real, own risk-category case for asking first, the same
33
+ * "different shape, not a lower bar" framing already applied to SSN/card/
34
+ * IBAN. Kept in the same flag rather than a new one to avoid flag
35
+ * proliferation for what is, mechanically, the same "opt in, then
36
+ * checksum-validate before ever reporting" pattern.
37
+ *
38
+ * Four detectors, deliberately not more: DidILeak's own shipped list also
21
39
  * includes bare email addresses and phone numbers, but both are far too
22
40
  * common in ordinary, non-sensitive text (a support email in a comment, a
23
41
  * phone number in an error message) to meet this project's own
24
42
  * "high-confidence only, a security tool that cries wolf gets
25
43
  * uninstalled" bar, opt-in or not -- DidILeak itself rates them "low"/
26
- * "info" severity for the same reason. The three included here all have a
44
+ * "info" severity for the same reason. Every detector here instead has a
27
45
  * REAL mathematical validator, not just a shape match, which is what
28
46
  * keeps false-positive risk low enough to ship even as an additive
29
- * category: Luhn for card numbers, ISO 7064 MOD 97-10 for IBAN, and the
30
- * Social Security Administration's own published invalid-range rules for
31
- * SSNs (no checksum exists for SSNs, hence "medium" confidence there, not
32
- * "high" -- disclosed, not smoothed over).
47
+ * category: Luhn for card numbers, ISO 7064 MOD 97-10 for IBAN, the BIP-39
48
+ * spec's own SHA-256 checksum for seed phrases, and the Social Security
49
+ * Administration's own published invalid-range rules for SSNs (no
50
+ * checksum exists for SSNs, hence "medium" confidence there, not "high"
51
+ * -- disclosed, not smoothed over).
33
52
  */
34
53
 
35
54
  /** Luhn checksum (ISO/IEC 7812-1): the standard validator for payment card numbers. `digits` must already be digits-only. */
@@ -72,6 +91,75 @@ function ibanValid(iban) {
72
91
  return mod === 1;
73
92
  }
74
93
 
94
+ const BIP39_INDEX = new Map(BIP39_ENGLISH_WORDLIST.map((w, i) => [w, i]));
95
+ const BIP39_VALID_LENGTHS = new Set([12, 15, 18, 21, 24]);
96
+
97
+ /**
98
+ * BIP-39 mnemonic checksum (github.com/bitcoin/bips/blob/master/bip-0039.mediawiki,
99
+ * fetched directly, cross-checked against the spec's own canonical all-zero
100
+ * test vector -- "abandon" x11 + "about" -- rather than trusted from memory):
101
+ * a mnemonic of MS words (12/15/18/21/24) encodes ENT bits of entropy plus a
102
+ * CS = ENT/32-bit checksum, each word an 11-bit index into the wordlist. The
103
+ * checksum is the first CS bits of SHA-256(entropy); this recomputes it and
104
+ * compares. English wordlist only -- BIP-39 also defines Japanese, Korean,
105
+ * Spanish, Chinese, French, Italian, Czech, and Portuguese wordlists this
106
+ * does not check, a disclosed scope limit rather than a silent one, the same
107
+ * shape as ibanValid's own per-country-length gap above.
108
+ *
109
+ * BigInt throughout: a 24-word phrase packs 264 bits, far past a safe JS
110
+ * integer, and a mis-sized intermediate here would silently corrupt every
111
+ * checksum it touches rather than throw.
112
+ */
113
+ function bip39ChecksumValid(words) {
114
+ if (!Array.isArray(words) || !BIP39_VALID_LENGTHS.has(words.length)) return false;
115
+ const indices = [];
116
+ for (const w of words) {
117
+ const idx = BIP39_INDEX.get(w);
118
+ if (idx === undefined) return false;
119
+ indices.push(idx);
120
+ }
121
+ const csBits = words.length / 3; // MS/3, derived from CS=ENT/32 and MS=(ENT+CS)/11
122
+ const entBits = words.length * 11 - csBits;
123
+
124
+ let combined = 0n;
125
+ for (const idx of indices) combined = (combined << 11n) | BigInt(idx);
126
+
127
+ const checksumMask = (1n << BigInt(csBits)) - 1n;
128
+ const checksumBits = combined & checksumMask;
129
+ const entropyBits = combined >> BigInt(csBits);
130
+
131
+ const entHex = entropyBits.toString(16).padStart(entBits / 8 * 2, "0");
132
+ const hash = crypto.createHash("sha256").update(Buffer.from(entHex, "hex")).digest();
133
+ const hashBits = BigInt("0x" + hash.toString("hex"));
134
+ const expectedChecksum = (hashBits >> BigInt(256 - csBits)) & checksumMask;
135
+
136
+ return checksumBits === expectedChecksum;
137
+ }
138
+
139
+ /**
140
+ * A real seed phrase is rarely the WHOLE candidate span: "here's my wallet
141
+ * seed: <12 words> keep it safe" is a plausible, ordinary way to paste one,
142
+ * and the candidate regex below (deliberately wide, to not miss a phrase
143
+ * that isn't sentence-initial) captures the surrounding words too. Slides
144
+ * every valid length (24 down to 12, longest first so a real 15+-word
145
+ * phrase is reported whole rather than as a coincidentally-checksum-valid
146
+ * 12-word prefix of it) across every starting offset in the captured run,
147
+ * returning the first exact substring whose checksum validates, or null.
148
+ * O(words x 5) checksum computations worst case -- cheap, since the regex
149
+ * itself already bounds "words" to at most 100.
150
+ */
151
+ function findBip39Phrase(candidateRun) {
152
+ const words = candidateRun.split(" ");
153
+ for (let start = 0; start < words.length; start++) {
154
+ for (const len of [24, 21, 18, 15, 12]) {
155
+ if (start + len > words.length) continue;
156
+ const slice = words.slice(start, start + len);
157
+ if (bip39ChecksumValid(slice)) return slice.join(" ");
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+
75
163
  const PII_PATTERNS = [
76
164
  // Dashed format only -- a bare 9-digit run is indistinguishable from
77
165
  // countless other numbers in a coding-agent transcript (ports, PIDs,
@@ -91,6 +179,21 @@ const PII_PATTERNS = [
91
179
  { id: "iban", label: "IBAN (checksum-validated)", confidence: "high",
92
180
  re: /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/g,
93
181
  validate: (m) => ibanValid(m) },
182
+ // Candidate: a run of 12-100 lowercase words, single-space-separated (how
183
+ // a seed phrase actually appears -- pasted as plain text, no punctuation
184
+ // breaking it up). Deliberately wider than the 12-24 a phrase itself can
185
+ // be: a real phrase is rarely the WHOLE sentence ("here's my seed: <12
186
+ // words> keep it safe"), so the candidate must be free to capture
187
+ // leading/trailing prose too -- findBip39Phrase (below) narrows it back
188
+ // down. validate() returns the narrowed substring, not a boolean: common
189
+ // English function words like "the"/"and"/"of"/"is" are NOT in the
190
+ // 2048-word list (confirmed by direct check against the fetched
191
+ // wordlist), so ordinary prose almost never survives even the membership
192
+ // test, let alone the checksum, but the narrowing still matters whenever
193
+ // it does.
194
+ { id: "crypto_seed_phrase", label: "Crypto wallet seed phrase (BIP-39, checksum-validated)", confidence: "high",
195
+ re: /\b[a-z]+(?: [a-z]+){11,99}\b/g,
196
+ validate: (m) => findBip39Phrase(m) },
94
197
  ];
95
198
 
96
- module.exports = { PII_PATTERNS, luhnValid, ibanValid };
199
+ module.exports = { PII_PATTERNS, luhnValid, ibanValid, bip39ChecksumValid, findBip39Phrase };
package/src/rotation.js CHANGED
@@ -1181,6 +1181,23 @@ const ROTATION_GUIDANCE = {
1181
1181
  ],
1182
1182
  revokeNote: "This passed the real ISO 7064 MOD 97-10 checksum every valid IBAN must satisfy, so it is very unlikely to be a random-looking placeholder.",
1183
1183
  },
1184
+ // Framed like every credential rule above, not like the two PII entries
1185
+ // just above it: unlike an SSN or an IBAN, a seed phrase is a bearer
1186
+ // credential with an actual "rotation" action available -- move the
1187
+ // funds to a new wallet -- so it belongs with "revoke and replace," not
1188
+ // "there is no console for this."
1189
+ crypto_seed_phrase: {
1190
+ label: "Crypto wallet seed phrase (BIP-39, checksum-validated)",
1191
+ consolePath: "No vendor console -- this is a self-custodied wallet's own master key, not an account credential a company can revoke for you.",
1192
+ steps: [
1193
+ "Treat the wallet as fully compromised the moment this leaves your control, even if you don't see funds move immediately -- anyone with the phrase can derive every key and address it controls",
1194
+ "Create a brand-new wallet from a freshly-generated seed phrase, on a device you trust, never by reusing or deriving from the exposed one",
1195
+ "Move all funds and any NFTs/tokens to the new wallet as soon as possible, paying attention to network fees so a move doesn't get front-run",
1196
+ "Update every dApp, exchange, or service that had the old wallet's address on file",
1197
+ "Remove the phrase from the transcript file if it doesn't need to be there -- residoo scan --seal quarantines the whole file without deleting anything, if you want a reversible first step",
1198
+ ],
1199
+ revokeNote: "This passed the real BIP-39 SHA-256 checksum every valid seed phrase must satisfy, so it is very unlikely to be a random-looking placeholder -- treat it as a real, live wallet key until shown otherwise.",
1200
+ },
1184
1201
 
1185
1202
  // ── NOISY_PATTERNS (only reachable via --include-noisy) ───────────────
1186
1203
  generic_password_assignment: {
package/src/scan.js CHANGED
@@ -609,21 +609,31 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
609
609
  // at all, so this mirrors decodeLine/ocrLine's simpler shape, not
610
610
  // matchLine's. `validate` (Luhn, IBAN's MOD 97-10) runs before a
611
611
  // candidate is even considered for suppression -- an invalid checksum
612
- // is not a "placeholder," it's simply not a match.
612
+ // is not a "placeholder," it's simply not a match. `validate` may return
613
+ // `true` (record the regex's own match verbatim -- Luhn/IBAN) or a
614
+ // narrower STRING (record that instead -- crypto_seed_phrase's
615
+ // findBip39Phrase, since the regex candidate deliberately over-captures
616
+ // surrounding prose and the checksum-valid phrase is usually a
617
+ // sub-string of it, not the whole match).
613
618
  const piiLine = (line, file, relFile, lineNo, mtimeMs) => {
614
619
  if (!includePii) return;
615
620
  for (const rule of PII_PATTERNS) {
616
621
  rule.re.lastIndex = 0;
617
622
  let m;
618
623
  while ((m = rule.re.exec(line)) !== null) {
619
- if (rule.validate && !rule.validate(m[0])) continue;
624
+ let value = m[0];
625
+ if (rule.validate) {
626
+ const v = rule.validate(m[0]);
627
+ if (!v) continue;
628
+ if (typeof v === "string") value = v;
629
+ }
620
630
  const before = line.slice(Math.max(0, m.index - CONTEXT_WINDOW), m.index);
621
- const suppressedReason = suppressionReason(m[0], before, rule.id);
631
+ const suppressedReason = suppressionReason(value, before, rule.id);
622
632
  if (suppressedReason && !includeSuppressed) {
623
633
  suppressedCount++;
624
634
  continue;
625
635
  }
626
- record(rule, m[0], relFile, file, lineNo, mtimeMs,
636
+ record(rule, value, relFile, file, lineNo, mtimeMs,
627
637
  suppressedReason ? "low" : rule.confidence, suppressedReason, { pii: true });
628
638
  }
629
639
  }
@@ -107,6 +107,18 @@ const { createInterface } = require("readline/promises");
107
107
  * (global config; their own security page recommends `chmod 600` on
108
108
  * it, a vendor admission it holds secrets) + named verbatim in JFrog's
109
109
  * Bitwarden-CLI target list.
110
+ * - `~/.mcp.json` (bare, directly under HOME, distinct from every
111
+ * subdirectory-nested path above) — Visual Studio (the full Windows
112
+ * IDE, not VS Code; VS Code's own MCP config is `.vscode/mcp.json`,
113
+ * already project-scoped below) documents this exact path as its
114
+ * global, all-solutions MCP config: Microsoft's own docs repo
115
+ * (github.com/MicrosoftDocs/visualstudio-docs, docs/ide/mcp-servers.md,
116
+ * fetched directly), "Serves as a global MCP server configuration for
117
+ * a specific user. Adding an MCP server here makes it load for all
118
+ * Visual Studio solutions." Visual Studio's remote-MCP OAuth tokens go
119
+ * through "the Visual Studio keychain" per the same doc -- an OS-backed
120
+ * store, not a plaintext file, so there is no separate credential
121
+ * vault to name here the way `.codex/auth.json` needed one.
110
122
  *
111
123
  * DELIBERATELY NOT READ, and why:
112
124
  * - `~/.claude/projects/**` AS SCAN CONTENT — claude-code.js's territory;
@@ -194,6 +206,7 @@ const CANDIDATES = [
194
206
  path.join(GEMINI_DIR, "settings.json"),
195
207
  path.join(CODEX_HOME, "config.toml"),
196
208
  path.join(KIRO_DIR, "settings", "mcp.json"),
209
+ path.join(HOME, ".mcp.json"),
197
210
  ];
198
211
 
199
212
  // Configs are KB-scale in every real observation this source's research
@@ -229,7 +242,12 @@ function available() {
229
242
  dirExists(CURSOR_DIR) ||
230
243
  dirExists(GEMINI_DIR) ||
231
244
  dirExists(CODEX_HOME) ||
232
- dirExists(KIRO_DIR)
245
+ dirExists(KIRO_DIR) ||
246
+ // Visual Studio (the full IDE) has no dedicated root directory the way
247
+ // every other tool above does -- its one machine-level artifact is this
248
+ // bare file, so it needs its own direct existence check rather than a
249
+ // dirExists() on some ~/.visualstudio root that doesn't exist.
250
+ fileExists(path.join(HOME, ".mcp.json"))
233
251
  );
234
252
  }
235
253
 
@@ -319,6 +337,13 @@ const PROJECT_CONFIG_RELPATHS = [
319
337
  ".mcp.json",
320
338
  path.join(".claude", "settings.json"),
321
339
  path.join(".claude", "settings.local.json"),
340
+ // Visual Studio's per-solution, VS-only MCP config: Microsoft's own docs
341
+ // (same source cited for ~/.mcp.json above) list this as a distinct
342
+ // location from <SOLUTIONDIR>/.mcp.json (source-controlled) --
343
+ // "Specific to Visual Studio and loads the specified MCP servers only
344
+ // for a specific user, for the specified solution," living inside the
345
+ // hidden, not-source-controlled .vs/ folder Visual Studio already owns.
346
+ path.join(".vs", "mcp.json"),
322
347
  ];
323
348
 
324
349
  // Probe bounds: transcript first-records are KB-scale; 256KB and 20 lines is
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() {