residoo 0.20.0 → 0.22.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
@@ -251,10 +251,11 @@ prompt. There is no recovery if you lose it, so pick one you keep.
251
251
 
252
252
  ## Sources supported today
253
253
 
254
- 44 sources, real-install-verified for Claude Code and its config family,
255
- multi-source-corroborated for the rest (Cursor, Codex CLI, Cline, Windsurf,
256
- Gemini CLI, Copilot, and 30+ more). Full list, what "corroborated" means,
257
- and how to add one: [docs/sources.md](docs/sources.md).
254
+ 45 sources, real-install-verified for Claude Code, its config family, and
255
+ bash/Python-REPL shell history, multi-source-corroborated for the rest
256
+ (Cursor, Codex CLI, Cline, Windsurf, Gemini CLI, Copilot, and 30+ more).
257
+ Full list, what "corroborated" means, and how to add one:
258
+ [docs/sources.md](docs/sources.md).
258
259
 
259
260
  ## License
260
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.20.0",
3
+ "version": "0.22.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
@@ -165,6 +165,27 @@ Scan options:
165
165
  excludes bare email/phone (too common in
166
166
  ordinary text to meet this
167
167
  project's own high-confidence bar even opt-in).
168
+ --include-injection also scan transcript content for prompt-
169
+ injection signatures: special/role-token
170
+ sequences (<|im_start|>, [INST], <<SYS>>, and
171
+ similar -- the control tokens an attacker can
172
+ smuggle into fetched content to make a model
173
+ treat it as a privileged turn instead of
174
+ untrusted data) and hidden instructions carried
175
+ by invisible Unicode. A third RISK CATEGORY,
176
+ neither a credential nor PII: this looks for
177
+ evidence an injection attempt already reached
178
+ the agent, in the same at-rest transcript
179
+ content every other pass scans -- not a
180
+ static-analysis check of an application's own
181
+ prompt-construction code (that's a different,
182
+ much bigger product; see docs/comparison.md).
183
+ Combine with --include-noisy for a small set of
184
+ canonical override phrases ("ignore previous
185
+ instructions" and close variants) -- disclosed
186
+ as genuinely heuristic and prone to matching a
187
+ security-research conversation about this exact
188
+ technique, not a solved detection problem.
168
189
 
169
190
  Watch:
170
191
  residoo watch continuous scanning instead of one snapshot:
@@ -182,7 +203,8 @@ Watch:
182
203
  --verify same opt-in vendor check as scan --verify,
183
204
  applied to each newly found credential once,
184
205
  never to one already seen
185
- --include-noisy, --include-suppressed, --include-pii, --no-color
206
+ --include-noisy, --include-suppressed, --include-pii,
207
+ --include-injection, --no-color
186
208
  same meaning as scan
187
209
  --no-notify skip the OS desktop notification watch fires for
188
210
  each genuinely new finding (macOS via osascript,
@@ -774,6 +796,7 @@ async function runWatch(args) {
774
796
  const verify = args.includes("--verify");
775
797
  const noColor = args.includes("--no-color");
776
798
  const includePii = args.includes("--include-pii");
799
+ const includeInjection = args.includes("--include-injection");
777
800
  const noNotify = args.includes("--no-notify");
778
801
 
779
802
  let intervalSeconds = 5;
@@ -800,7 +823,7 @@ async function runWatch(args) {
800
823
 
801
824
  const { promise, stop } = startWatch({
802
825
  sources,
803
- options: { includeNoisy, includeSuppressed, verify, noColor, includePii, noNotify, json: wantsJson, pollMs: intervalSeconds * 1000 },
826
+ options: { includeNoisy, includeSuppressed, verify, noColor, includePii, includeInjection, noNotify, json: wantsJson, pollMs: intervalSeconds * 1000 },
804
827
  });
805
828
 
806
829
  const printFinalSummary = (stats) => {
@@ -1098,6 +1121,11 @@ async function main(argv) {
1098
1121
  // card numbers, IBAN) rather than the shape-only, much noisier
1099
1122
  // categories (bare email, phone) some competitors also ship.
1100
1123
  const wantsPii = args.includes("--include-pii");
1124
+ // --include-injection: a third, separate risk category from either of the
1125
+ // above (see injection.js) -- detects a realized prompt-injection
1126
+ // signature already sitting in transcript content, not a credential or
1127
+ // personal data.
1128
+ const wantsInjection = args.includes("--include-injection");
1101
1129
 
1102
1130
  // --project [dir]: the dir is optional (CI passes ".", a bare --project
1103
1131
  // means the current directory). null means machine mode.
@@ -1209,7 +1237,7 @@ async function main(argv) {
1209
1237
 
1210
1238
  const progress = makeProgressReporter(noColor);
1211
1239
  const result = await scan({
1212
- sources, includeNoisy, includeSuppressed, verify, noColor, ocr: wantsOcr, includePii: wantsPii,
1240
+ sources, includeNoisy, includeSuppressed, verify, noColor, ocr: wantsOcr, includePii: wantsPii, includeInjection: wantsInjection,
1213
1241
  onProgress: progress.onProgress,
1214
1242
  // Clears the spinner's last frame before --verify's own stderr lines
1215
1243
  // print; without this the last spinner line sits uncleared on screen
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+
3
+ const { scanZeroWidth } = require("./integrity");
4
+
5
+ /**
6
+ * Prompt-injection signature detection, applied to the SAME transcript
7
+ * content every other pass already reads (tool_result blocks, fetched-page
8
+ * text, file contents an agent read, ordinary message text) -- no new
9
+ * source, no new file walk, just a second/third rule set matched against
10
+ * lines scan.js already has in memory. Opt-in via `--include-injection`,
11
+ * the same "different risk category, not a lower-confidence secret"
12
+ * reasoning pii.js's own header states for `--include-pii`.
13
+ *
14
+ * WHAT THIS IS NOT, stated up front because it is the single most important
15
+ * scope distinction here: this is NOT a static-analysis scanner for an LLM
16
+ * APPLICATION'S OWN SOURCE CODE (an f-string concatenating user input into a
17
+ * prompt, unsanitized external content reaching a prompt template). That is
18
+ * a real, different product -- it's what Medusa's own PI-SCAN does (checked
19
+ * directly against Medusa's own docs/AI_SECURITY.md, fetched 2026-09-05:
20
+ * "Direct Injection: f-string interpolation with user_input... Indirect
21
+ * Injection: External content fetched and embedded in prompts without
22
+ * sanitization" -- both examples are about auditing an application's PROMPT
23
+ * -CONSTRUCTION code for a latent vulnerability class). residoo has no
24
+ * access to that code and isn't built to read it; what residoo already has,
25
+ * uniquely, is the agent's own TRANSCRIPT -- a record of what actually got
26
+ * fed to a live agent. So this module detects INJECTION PAYLOADS THAT
27
+ * ALREADY REACHED AN AGENT, sitting in the same at-rest data every other
28
+ * residoo pass scans -- a genuinely different, arguably more valuable
29
+ * signal (a realized attempt, not a hypothetical vulnerable code path), not
30
+ * an attempt to clone Medusa's SAST feature with a worse implementation.
31
+ *
32
+ * SIGNAL SOURCES, verified 2026-09-05:
33
+ *
34
+ * - **Special/role-token injection**: `<|im_start|>`, `<|im_end|>`,
35
+ * `<|system|>`, `<|user|>`, `<|assistant|>`, `<|endoftext|>`,
36
+ * `<|endofprompt|>`, `[INST]`/`[/INST]`, `<<SYS>>`/`<</SYS>>` -- the
37
+ * control tokens chat-templated models use to delineate a message's
38
+ * ROLE. An attacker who gets one of these into content an agent reads
39
+ * (a fetched webpage, a file, a tool's output) can, on a vulnerable
40
+ * serving pipeline, make the model treat injected text as a new
41
+ * system/assistant turn rather than untrusted data. This is a named,
42
+ * real technique -- "Special Token Injection" (Sentry's own STI attack
43
+ * guide, blog.sentry.security/special-token-injection-sti-attack-guide,
44
+ * fetched directly: "the model expects certain token patterns to
45
+ * signify roles... if the... pipeline does not properly filter or
46
+ * escape these sequences, an attacker's input will reach the model...
47
+ * analogous to injecting a SQL query via an input field"), corroborated
48
+ * by OWASP's LLM01 Prompt Injection entry (genai.owasp.org) and a 2026
49
+ * arXiv paper specifically on chat-template abuse for indirect
50
+ * injection ("ChatInject: Abusing Chat Templates for Prompt Injection
51
+ * in LLM Agents," arxiv.org/abs/2509.22830) -- not one vendor's
52
+ * unverified claim. Medusa's own docs name this same technique family
53
+ * ("Code-Level Prompt Injection... ChatML tokens, role manipulation"),
54
+ * confirming it's a real, converged-upon signal, not something invented
55
+ * here. HIGH confidence: these exact token strings essentially never
56
+ * appear in ordinary prose or code by accident -- the honest, disclosed
57
+ * exception is a message that *discusses* these tokens by name (a
58
+ * tokenizer bug report, this very file's own docstring) rather than
59
+ * attempting to use them, the same "a real key a user pasted to ask
60
+ * about it" false-positive class patterns.js's private_key_block rule
61
+ * already carries.
62
+ * - **Hidden/invisible Unicode**: reuses `scanZeroWidth` from
63
+ * `integrity.js` verbatim (see that function's own docstring for the
64
+ * TrapDoor campaign citation and the always-suspicious/context-
65
+ * dependent tiering) -- extended here to every line of every
66
+ * transcript this project reads, not only the fixed CLAUDE.md/memory-
67
+ * file locations `checkIntegrity` already covers. This closes a real
68
+ * gap in the existing coverage: a hidden instruction delivered via a
69
+ * fetched web page or a tool's own output lands in ordinary transcript
70
+ * content, not in one of `checkIntegrity`'s known config paths, so the
71
+ * existing check cannot see it.
72
+ *
73
+ * NOISY_INJECTION_PATTERNS (opt-in ADDITIONALLY via `--include-noisy`,
74
+ * exactly mirroring patterns.js's own NOISY_PATTERNS contract -- "broader,
75
+ * shape-based patterns that catch more but false-positive more often"):
76
+ * a small set of the most-cited canonical instruction-override phrases
77
+ * ("ignore previous instructions" and its close variants). Disclosed
78
+ * plainly, not glossed over: phrase-based matching is genuinely prone to
79
+ * matching a security-research conversation, a GitHub issue about prompt
80
+ * injection, or this very codebase's own documentation discussing the
81
+ * technique -- OWASP's own LLM01 page and multiple practitioner write-ups
82
+ * (Simon Willison's "prompt injection" writing among them) describe
83
+ * reliable phrase-based detection as an open, unsolved problem, not
84
+ * something this rule set claims to have solved. LOW confidence, never
85
+ * part of the default report, for exactly that reason.
86
+ *
87
+ * WHAT THIS DOES NOT COVER, stated rather than silently gapped: tool-
88
+ * DESCRIPTION poisoning (a malicious MCP server changing a tool's
89
+ * description after approval, "rug-pull") is a real, named technique
90
+ * (Medusa's own "Tool Poisoning (MCP101)") that this module cannot check,
91
+ * because a tool's description is part of the MCP protocol payload sent to
92
+ * the model at request time, not something Claude Code's own transcript
93
+ * JSONL logs — verified directly against a real transcript on this
94
+ * project's own build machine: a `tool_use` record for an
95
+ * `mcp__`-namespaced tool carries only `{name, input}`, never the tool's
96
+ * description or input schema. Checking that would require a live MCP
97
+ * client connection to query `tools/list`, a fundamentally different
98
+ * architecture (an active protocol client, not a file scanner) that this
99
+ * project has not built and is not attempting to fake here.
100
+ */
101
+
102
+ const CHATML_TOKEN_RE = /<\|(?:im_start|im_end|system|user|assistant|endoftext|endofprompt)\|>|\[\/?INST\]|<<\/?SYS>>/g;
103
+
104
+ const INJECTION_PATTERNS = [
105
+ { id: "chatml_special_token", label: "Special/role-token injection (ChatML or similar)", confidence: "high" },
106
+ { id: "zero_width_hidden_instruction", label: "Hidden instruction carried by invisible Unicode", confidence: "high" },
107
+ ];
108
+
109
+ const NOISY_INJECTION_PATTERNS = [
110
+ {
111
+ id: "injection_override_phrase", label: "Instruction-override phrase (heuristic)", confidence: "low",
112
+ // Deliberately narrow: the small set of phrasings cited across OWASP's
113
+ // LLM01 page and independent practitioner write-ups as the canonical
114
+ // "ignore what came before" injection framing, not an attempt at
115
+ // exhaustive jailbreak-phrase coverage (see module docstring on why
116
+ // phrase-based detection stays opt-in and low-confidence).
117
+ re: /\b(?:ignore|disregard)\s+(?:all\s+|any\s+)?(?:the\s+|your\s+)?(?:previous|prior|above|earlier)\s+instructions\b|\bforget\s+(?:everything|all)\s+(?:above|before\s+this)\b/gi,
118
+ },
119
+ ];
120
+
121
+ /**
122
+ * Minimal per-line invisible-character summary: codepoint name + count,
123
+ * no line-number list (unlike integrity.js's summarizeZeroWidth, which is
124
+ * built for a whole-file, many-line summary) -- the caller already has the
125
+ * real line number for this one call, so repeating it here would just be
126
+ * confusing "(line 1)" noise from scanZeroWidth's own internal, line-blind
127
+ * counting of a single line with no embedded newline.
128
+ */
129
+ function summarizeInvisibles(hits) {
130
+ const byCp = new Map();
131
+ for (const h of hits) byCp.set(h.cp, (byCp.get(h.cp) || 0) + 1);
132
+ const parts = [];
133
+ for (const [cp, count] of byCp) {
134
+ parts.push("U+" + cp.toString(16).toUpperCase().padStart(4, "0") + " ×" + count);
135
+ }
136
+ return parts.join(", ");
137
+ }
138
+
139
+ module.exports = { INJECTION_PATTERNS, NOISY_INJECTION_PATTERNS, CHATML_TOKEN_RE, summarizeInvisibles };
package/src/integrity.js CHANGED
@@ -836,4 +836,9 @@ function checkIntegrity({ home = os.homedir(), cwd = process.cwd(), projectMode
836
836
  };
837
837
  }
838
838
 
839
- module.exports = { checkIntegrity };
839
+ // scanZeroWidth is also reused by injection.js, applying the same
840
+ // TrapDoor-sourced invisible-character classification (see its own
841
+ // docstring above) to general transcript content, not just this file's
842
+ // own fixed config-location list -- additive export, this module's own
843
+ // behavior is unchanged.
844
+ module.exports = { checkIntegrity, scanZeroWidth };
package/src/mcpTools.js CHANGED
@@ -52,18 +52,20 @@ function rejectUnknownKeys(args, allowed) {
52
52
 
53
53
  /**
54
54
  * Shared arg shape for residoo_scan/residoo_check: includeNoisy,
55
- * includeSuppressed, includePii, maxEntries. includePii is exposed here
56
- * (unlike ocr or verify, see this file's own header comment on verify's
57
- * exclusion) because it is architecturally identical to includeNoisy --
58
- * local-only, no network call, no external process, just a different
59
- * detection category (see pii.js) -- not the network/live-secret trust
60
- * boundary verify's own exclusion is specifically about.
55
+ * includeSuppressed, includePii, includeInjection, maxEntries. includePii
56
+ * and includeInjection are exposed here (unlike ocr or verify, see this
57
+ * file's own header comment on verify's exclusion) because they are
58
+ * architecturally identical to includeNoisy -- local-only, no network
59
+ * call, no external process, just a different detection category (see
60
+ * pii.js and injection.js respectively) -- not the network/live-secret
61
+ * trust boundary verify's own exclusion is specifically about.
61
62
  */
62
63
  function validateSweepArgs(args, allowedKeys) {
63
64
  const errs = rejectUnknownKeys(args, allowedKeys);
64
65
  if (args.includeNoisy !== undefined && typeof args.includeNoisy !== "boolean") errs.push("includeNoisy must be a boolean");
65
66
  if (args.includeSuppressed !== undefined && typeof args.includeSuppressed !== "boolean") errs.push("includeSuppressed must be a boolean");
66
67
  if (args.includePii !== undefined && typeof args.includePii !== "boolean") errs.push("includePii must be a boolean");
68
+ if (args.includeInjection !== undefined && typeof args.includeInjection !== "boolean") errs.push("includeInjection must be a boolean");
67
69
  let maxEntries = 25;
68
70
  if (args.maxEntries !== undefined) {
69
71
  if (typeof args.maxEntries !== "number" || !Number.isInteger(args.maxEntries) || args.maxEntries < 1 || args.maxEntries > 200) {
@@ -74,7 +76,7 @@ function validateSweepArgs(args, allowedKeys) {
74
76
  }
75
77
  return {
76
78
  errs, includeNoisy: args.includeNoisy === true, includeSuppressed: args.includeSuppressed === true,
77
- includePii: args.includePii === true, maxEntries,
79
+ includePii: args.includePii === true, includeInjection: args.includeInjection === true, maxEntries,
78
80
  };
79
81
  }
80
82
 
@@ -134,8 +136,8 @@ function buildTools({ sources }) {
134
136
  let checkStarted = false;
135
137
 
136
138
  async function handleScan(args) {
137
- const SCAN_KEYS = new Set(["projectDir", "includeNoisy", "includeSuppressed", "includePii", "maxEntries"]);
138
- const { errs, includeNoisy, includeSuppressed, includePii, maxEntries } = validateSweepArgs(args, SCAN_KEYS);
139
+ const SCAN_KEYS = new Set(["projectDir", "includeNoisy", "includeSuppressed", "includePii", "includeInjection", "maxEntries"]);
140
+ const { errs, includeNoisy, includeSuppressed, includePii, includeInjection, maxEntries } = validateSweepArgs(args, SCAN_KEYS);
139
141
  if (args.projectDir !== undefined && typeof args.projectDir !== "string") errs.push("projectDir must be a string");
140
142
  if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
141
143
 
@@ -152,7 +154,7 @@ function buildTools({ sources }) {
152
154
  scanSources = sources;
153
155
  }
154
156
 
155
- const result = await scan({ sources: scanSources, includeNoisy, includeSuppressed, includePii, verify: false, noColor: true });
157
+ const result = await scan({ sources: scanSources, includeNoisy, includeSuppressed, includePii, includeInjection, verify: false, noColor: true });
156
158
  const acks = loadAcks();
157
159
  const dismissed = loadDismissed();
158
160
  const rotation = renderRotation(result.findings, acks, dismissed);
@@ -179,8 +181,8 @@ function buildTools({ sources }) {
179
181
  }
180
182
 
181
183
  async function handleCheck(args) {
182
- const CHECK_KEYS = new Set(["includeNoisy", "includeSuppressed", "includePii", "maxEntries"]);
183
- const { errs, includeNoisy, includeSuppressed, includePii, maxEntries } = validateSweepArgs(args, CHECK_KEYS);
184
+ const CHECK_KEYS = new Set(["includeNoisy", "includeSuppressed", "includePii", "includeInjection", "maxEntries"]);
185
+ const { errs, includeNoisy, includeSuppressed, includePii, includeInjection, maxEntries } = validateSweepArgs(args, CHECK_KEYS);
184
186
  if (errs.length) return errorResult(`Invalid arguments: ${errs.join("; ")}`);
185
187
 
186
188
  const firstCheckThisSession = !checkStarted;
@@ -191,7 +193,7 @@ function buildTools({ sources }) {
191
193
  const emit = (e) => events.push(e);
192
194
  const stats = await sweepOnce({
193
195
  sources, tracked: checkTracked, seen: checkSeen, ledger,
194
- options: { includeNoisy, includeSuppressed, includePii, verify: false, noColor: true }, emit,
196
+ options: { includeNoisy, includeSuppressed, includePii, includeInjection, verify: false, noColor: true }, emit,
195
197
  });
196
198
 
197
199
  const allNew = events.filter((e) => e.type === "finding");
@@ -366,6 +368,7 @@ function buildTools({ sources }) {
366
368
  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
369
  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
370
  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." },
371
+ includeInjection: { type: "boolean", default: false, description: "Also scan transcript content for prompt-injection signatures (special/role-token sequences like <|im_start|> or [INST], and hidden instructions carried by invisible Unicode) -- evidence an injection attempt already reached the agent, not a static-analysis check of application code. A third risk category, off by default." },
369
372
  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
373
  },
371
374
  required: [],
@@ -382,6 +385,7 @@ function buildTools({ sources }) {
382
385
  includeNoisy: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
383
386
  includeSuppressed: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
384
387
  includePii: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
388
+ includeInjection: { type: "boolean", default: false, description: "Same meaning as residoo_scan." },
385
389
  maxEntries: { type: "integer", minimum: 1, maximum: 200, default: 25, description: "Cap on new findings / re-exposures returned in full detail. Counts are always exact even when truncated." },
386
390
  },
387
391
  required: [],
package/src/report.js CHANGED
@@ -541,11 +541,15 @@ function renderJson(result, integrity = null, rotation = null) {
541
541
  // value was never plain text at all -- it was read out of a
542
542
  // pasted or tool-returned image (see ocr.js); `pii` means this is
543
543
  // a --include-pii finding, a different risk category from a
544
- // credential, not a rule from the default set (see pii.js).
544
+ // credential, not a rule from the default set (see pii.js);
545
+ // `injection` means this is a --include-injection finding -- a
546
+ // prompt-injection signature, not a credential or PII at all (see
547
+ // injection.js).
545
548
  ...(f.encoding ? { encoding: f.encoding } : {}),
546
549
  ...(f.spanLines ? { spanLines: f.spanLines } : {}),
547
550
  ...(f.ocr ? { ocr: true } : {}),
548
551
  ...(f.pii ? { pii: true } : {}),
552
+ ...(f.injection ? { injection: true } : {}),
549
553
  fingerprint: fingerprintFinding(f),
550
554
  // Only present on an --include-suppressed run: says WHY this finding
551
555
  // is low-confidence, so a JSON consumer doesn't have to guess.
package/src/rotation.js CHANGED
@@ -1222,6 +1222,43 @@ const ROTATION_GUIDANCE = {
1222
1222
  ],
1223
1223
  revokeNote: "Low-confidence match: verify before rotating anything.",
1224
1224
  },
1225
+
1226
+ // ── INJECTION_PATTERNS (--include-injection; see injection.js) ─────────
1227
+ // Framed like the PII entries above, not like a credential: there is no
1228
+ // issuer, no console, nothing to rotate. The real action is investigating
1229
+ // HOW this reached the transcript -- a fetched page, a file the agent
1230
+ // read, a tool's own output -- since that's the actual attack surface,
1231
+ // not the token/character itself.
1232
+ chatml_special_token: {
1233
+ label: "Special/role-token injection (ChatML or similar)",
1234
+ consolePath: "No vendor console -- this is a structural signature in content, not a credential.",
1235
+ steps: [
1236
+ "Find which tool call or fetched source produced the line this was found in -- that's the actual entry point, not this file",
1237
+ "If it came from external content (a web page, an API response, a file the agent read), treat that source as untrusted going forward and review what the agent did in the turns immediately after seeing it",
1238
+ "If this is a false positive -- code or documentation that legitimately discusses these tokens by name (a tokenizer bug report, this project's own docs) -- no action needed",
1239
+ ],
1240
+ revokeNote: "High confidence structurally (these exact token sequences are rare in ordinary prose/code), but confidence in the MATCH is not the same as confidence an attack succeeded -- whether it actually altered the agent's behavior depends on the specific model/serving pipeline, which this check cannot see.",
1241
+ },
1242
+ zero_width_hidden_instruction: {
1243
+ label: "Hidden instruction carried by invisible Unicode",
1244
+ consolePath: "No vendor console -- this is a structural signature in content, not a credential.",
1245
+ steps: [
1246
+ "Inspect the source file in a hex viewer or an editor that reveals invisible characters -- never trust how it renders in a normal terminal, that's the whole point of this technique",
1247
+ "Find which tool call or fetched source produced this line, the same way as the special-token rule above",
1248
+ "This is the same technique named in the TrapDoor campaign (see integrity.js's own citation) -- if this pattern shows up in a fixed config location (CLAUDE.md, a hook script) rather than ordinary transcript content, `residoo scan`'s own integrity check (not this rule) is what already covers that case with campaign-specific detail",
1249
+ ],
1250
+ revokeNote: "The always-suspicious codepoint tier (not the context-dependent emoji-joiner tier) is what reaches this rule -- see integrity.js's scanZeroWidth for exactly which codepoints qualify and why.",
1251
+ },
1252
+ injection_override_phrase: {
1253
+ label: "Instruction-override phrase (noisy rule)",
1254
+ generic: true,
1255
+ consolePath: "No vendor console -- this is a phrase match in content, not a credential.",
1256
+ steps: [
1257
+ "Read the surrounding context before treating this as a real attempt -- this exact phrase is also what a security-research conversation, a GitHub issue, or a prompt-engineering discussion about this technique looks like, and this rule cannot tell the difference",
1258
+ "If it's a real attempt, find which tool call or fetched source it came from",
1259
+ ],
1260
+ revokeNote: "Low-confidence, phrase-based match: OWASP's own LLM01 guidance and independent practitioner writing both describe reliable phrase-based injection detection as unsolved, not something this rule claims to have done.",
1261
+ },
1225
1262
  };
1226
1263
  Object.freeze(ROTATION_GUIDANCE);
1227
1264
 
package/src/scan.js CHANGED
@@ -5,6 +5,8 @@ const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
5
5
  const { findDecodedMatches, findBoundaryMatches, contentProjection } = require("./decode");
6
6
  const { isTesseractAvailable, extractImageBlocks, ocrImageBase64 } = require("./ocr");
7
7
  const { PII_PATTERNS } = require("./pii");
8
+ const { INJECTION_PATTERNS, NOISY_INJECTION_PATTERNS, CHATML_TOKEN_RE, summarizeInvisibles } = require("./injection");
9
+ const { scanZeroWidth } = require("./integrity");
8
10
  const { findPairedSecret, findNearbyCandidate } = require("./pairing");
9
11
  const { looksRandom } = require("./rarity");
10
12
  const { decodeJwtExpiryMs } = require("./jwtExpiry");
@@ -273,7 +275,7 @@ function localTimestamp(d) {
273
275
  * absolute path can itself carry a username or a project name the rest of
274
276
  * this report is careful never to print.
275
277
  */
276
- async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, verifyOnlyFingerprint = null, onBeforeVerify = null, noColor = false, ocr = false, includePii = false } = {}) {
278
+ async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null, verify = false, verifyOnlyFingerprint = null, onBeforeVerify = null, noColor = false, ocr = false, includePii = false, includeInjection = false } = {}) {
277
279
  const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
278
280
  // --ocr: checked once, not per line/image -- isTesseractAvailable shells
279
281
  // out, and this scan can touch thousands of lines. ocrRequestedButMissing
@@ -639,6 +641,40 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
639
641
  }
640
642
  };
641
643
 
644
+ // --include-injection: a third, separate risk category (see injection.js's
645
+ // own header for why this is neither a secret nor PII). No suppression
646
+ // heuristics apply here -- there is no "vendor-documented example" or
647
+ // "placeholder-like context" equivalent for a special-token sequence or a
648
+ // hidden Unicode character, unlike a value-shaped secret. NOISY_INJECTION_
649
+ // PATTERNS additionally require --include-noisy, mirroring exactly how
650
+ // patterns.js's own NOISY_PATTERNS require it for secrets.
651
+ const injectionLine = (line, file, relFile, lineNo, mtimeMs) => {
652
+ if (!includeInjection) return;
653
+ for (const rule of INJECTION_PATTERNS) {
654
+ if (rule.id === "chatml_special_token") {
655
+ CHATML_TOKEN_RE.lastIndex = 0;
656
+ let m;
657
+ while ((m = CHATML_TOKEN_RE.exec(line)) !== null) {
658
+ record(rule, m[0], relFile, file, lineNo, mtimeMs, rule.confidence, null, { injection: true });
659
+ }
660
+ } else if (rule.id === "zero_width_hidden_instruction") {
661
+ const hits = scanZeroWidth(line).filter((h) => h.suspicious);
662
+ if (hits.length > 0) {
663
+ record(rule, summarizeInvisibles(hits), relFile, file, lineNo, mtimeMs, rule.confidence, null, { injection: true });
664
+ }
665
+ }
666
+ }
667
+ if (includeNoisy) {
668
+ for (const rule of NOISY_INJECTION_PATTERNS) {
669
+ rule.re.lastIndex = 0;
670
+ let m;
671
+ while ((m = rule.re.exec(line)) !== null) {
672
+ record(rule, m[0], relFile, file, lineNo, mtimeMs, rule.confidence, null, { injection: true });
673
+ }
674
+ }
675
+ }
676
+ };
677
+
642
678
  // Feature 2: split-line boundary join. A finding here means one credential
643
679
  // was split across this line and the next and is contiguous on neither. It
644
680
  // is recorded against BOTH contributing lines (each holds a fragment of the
@@ -763,6 +799,13 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
763
799
  flagFailed();
764
800
  }
765
801
  }
802
+ if (includeInjection) {
803
+ try {
804
+ injectionLine(line, file, relFile, i + 1, mtimeMs);
805
+ } catch (err) {
806
+ flagFailed();
807
+ }
808
+ }
766
809
  try {
767
810
  const content = contentProjection(line);
768
811
  // Boundary join with the previous line (2-way splits only; see
@@ -140,6 +140,13 @@ const atlassianRovoDev = require("./atlassian-rovo-dev");
140
140
  // cache/offline copy of thread content is documented anywhere found — the
141
141
  // same cloud-only reasoning as Augment Code/CodeGPT above, not missed.
142
142
 
143
+ // Not a transcript store either — interactive shell/REPL history (bash,
144
+ // zsh, fish, psql, mysql, Python, Node.js). See shell-history.js's own
145
+ // header for why this is in scope despite not being literally "an AI
146
+ // agent's session history," and for exactly which paths are
147
+ // real-install-verified versus documented-but-unverified on this machine.
148
+ const shellHistory = require("./shell-history");
149
+
143
150
  const ALL_SOURCES = [
144
151
  claudeCode,
145
152
  agentConfigs,
@@ -185,6 +192,7 @@ const ALL_SOURCES = [
185
192
  kimiCode,
186
193
  fx,
187
194
  atlassianRovoDev,
195
+ shellHistory,
188
196
  ];
189
197
 
190
198
  function availableSources() {
@@ -0,0 +1,241 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+ const { createInterface } = require("readline/promises");
7
+
8
+ /**
9
+ * Interactive shell and REPL/DB-client history files.
10
+ *
11
+ * SCOPE, stated plainly because this is the second source in this project
12
+ * (after agent-configs.js) that is not literally "an AI agent's session
13
+ * history": these files record what the DEVELOPER typed at a real
14
+ * interactive prompt, not what an agent wrote to disk on their behalf. It's
15
+ * included because it's the same failure mode, one hop away from what this
16
+ * tool already covers: a developer tests a curl call with a bearer token
17
+ * before pasting the working version into an agent prompt, connects to a
18
+ * database with a password embedded in the URI, or exports a token into a
19
+ * REPL to try a client library — plaintext, indefinitely, in a file almost
20
+ * nobody thinks to check, the exact description this project's own README
21
+ * opens with. It is also a real, disclosed, competitor-named gap: Medusa
22
+ * (see docs/comparison.md's Medusa section) already scans exactly
23
+ * bash/zsh/fish/psql/mysql/python-REPL history and residoo did not.
24
+ *
25
+ * Every path below is a documented default or a documented override
26
+ * environment variable from that tool's own primary docs, fetched directly
27
+ * (not assumed by analogy to a similar tool) on 2026-09-05:
28
+ *
29
+ * - **bash**: `~/.bash_history` is bash's own long-standing built-in
30
+ * default (its manual page).
31
+ * - **zsh**: NOT a shell-level default the way bash's is — zsh's own
32
+ * manual (zsh.sourceforge.io/Doc/Release/Parameters.html) states
33
+ * plainly that if `HISTFILE` is unset, "the history is not saved" at
34
+ * all. `~/.zsh_history` is checked anyway because it's the exact path
35
+ * zsh's own bundled `zsh-newuser-install` script offers a new user who
36
+ * accepts history saving, and the default both Oh My Zsh's and
37
+ * Prezto's stock templates set — the de facto convention on most real
38
+ * machines, not a shell-level guarantee. `$HISTFILE`, when set, is
39
+ * checked once for both bash and zsh: a set value can't be attributed
40
+ * to one shell over the other from outside the shell itself.
41
+ * - **fish**: `$XDG_DATA_HOME/fish/fish_history`, defaulting to
42
+ * `~/.local/share/fish/fish_history` when that variable is unset —
43
+ * fish's own docs (fishshell.com/docs/current/interactive.html) state
44
+ * this exact default and XDG override.
45
+ * - **psql**: `~/.psql_history` (Unix) or
46
+ * `%APPDATA%\postgresql\psql_history` (Windows) — PostgreSQL's own
47
+ * psql docs (postgresql.org/docs/current/app-psql.html) state both
48
+ * paths as the default. No environment-variable override is
49
+ * documented; psql's own `\set HISTFILE ...` is an in-session psql
50
+ * variable, not a process environment variable residoo can read from
51
+ * outside the running psql process.
52
+ * - **mysql**: `$MYSQL_HISTFILE`, defaulting to `~/.mysql_history` —
53
+ * MySQL's own reference manual (dev.mysql.com/doc/refman/8.4/en/
54
+ * mysql-logging.html) documents both, and its own text recommends
55
+ * restricting this file's permissions because it "may contain
56
+ * sensitive information" — a vendor admission of exactly the failure
57
+ * mode this source exists to catch.
58
+ * - **Python** (interactive interpreter): `$PYTHON_HISTORY`, defaulting
59
+ * to `~/.python_history` — Python's own docs (docs.python.org/3/using/
60
+ * cmdline.html). The environment variable is Python 3.13+ only (added
61
+ * that release); reading it on an older interpreter simply finds it
62
+ * unset and falls through to the same fixed default, so no version
63
+ * check is needed here.
64
+ * - **Node.js REPL**: `$NODE_REPL_HISTORY`, defaulting to
65
+ * `~/.node_repl_history` — Node's own docs (nodejs.org/api/repl.html),
66
+ * which also document that an empty or whitespace-only value means the
67
+ * user explicitly disabled persistent history; honored here exactly as
68
+ * documented rather than treated as "unset."
69
+ *
70
+ * VERIFICATION STATUS: `~/.bash_history` and `~/.python_history` are
71
+ * REAL-INSTALL-VERIFIED — both exist on this project's own build machine
72
+ * with genuine, non-empty content (confirmed directly, read-only, before
73
+ * this source was written). zsh/fish/psql/mysql/Node history are
74
+ * MULTI-SOURCE-CORROBORATED-BUT-UNVERIFIED: each path above comes from
75
+ * that tool's own primary documentation, but none of those five files
76
+ * exist on the machine this was built on, so the schema (there isn't
77
+ * one — every one of these is already plain line-delimited text) is
78
+ * unverified against real content the same way most of this project's
79
+ * other sources are. If you use zsh, fish, psql, mysql, or the Node REPL
80
+ * with a populated history file, running `residoo scan` and confirming
81
+ * `filesScanned` looks right is the single most useful way to firm this
82
+ * up (see CONTRIBUTING.md).
83
+ *
84
+ * FORMAT: every one of these files is already plain line-delimited text —
85
+ * zsh's optional "extended history" format (`: <ts>:<secs>;<command>`) and
86
+ * fish's YAML-ish `- cmd: ...` / ` when: ...` records still carry the
87
+ * actual command as a contiguous substring of one line, and every pattern
88
+ * in `src/patterns.js` matches on `\b` word boundaries, never a `^`
89
+ * line-start anchor — so no format-specific parsing is needed before
90
+ * pattern matching, the same reasoning agent-configs.js's readLines()
91
+ * docstring states for JSON/TOML config lines, and decode.js's own header
92
+ * already anticipates this exact case ("plain-text chat logs, shell
93
+ * history... used as-is").
94
+ *
95
+ * NOT covered, and why: shell history for any shell/tool not named above
96
+ * (fish's own `fish_history` predecessor formats, csh/tcsh, sqlite3's
97
+ * `.sqlite_history`, R's `.Rhistory`, IPython's separate SQLite-backed
98
+ * history database) — each would need its own documented default and
99
+ * schema check to the same bar as the seven above, not guessed by
100
+ * analogy. A welcome follow-up PR, per CONTRIBUTING.md.
101
+ */
102
+
103
+ const HOME = os.homedir();
104
+
105
+ function id() { return "shell-history"; }
106
+ function label() { return "Shell & REPL history"; }
107
+
108
+ /**
109
+ * Every candidate path this source checks, deduplicated (a customized
110
+ * override that happens to equal a default above would otherwise be
111
+ * checked twice). Order has no behavioral meaning — statIfPresent handles
112
+ * each independently.
113
+ */
114
+ function candidatePaths() {
115
+ const xdgDataHome = process.env.XDG_DATA_HOME || path.join(HOME, ".local", "share");
116
+ const nodeReplHistory = process.env.NODE_REPL_HISTORY;
117
+ const nodeReplDisabled = nodeReplHistory !== undefined && nodeReplHistory.trim() === "";
118
+
119
+ const paths = [
120
+ path.join(HOME, ".bash_history"),
121
+ path.join(HOME, ".zsh_history"),
122
+ ...(process.env.HISTFILE ? [process.env.HISTFILE] : []),
123
+ path.join(xdgDataHome, "fish", "fish_history"),
124
+ process.platform === "win32"
125
+ ? path.join(process.env.APPDATA || path.join(HOME, "AppData", "Roaming"), "postgresql", "psql_history")
126
+ : path.join(HOME, ".psql_history"),
127
+ process.env.MYSQL_HISTFILE || path.join(HOME, ".mysql_history"),
128
+ process.env.PYTHON_HISTORY || path.join(HOME, ".python_history"),
129
+ ...(nodeReplDisabled ? [] : [nodeReplHistory || path.join(HOME, ".node_repl_history")]),
130
+ ];
131
+
132
+ return [...new Set(paths)];
133
+ }
134
+
135
+ /**
136
+ * Resolve one fixed candidate path into zero or one files() entries.
137
+ * Duplicated from agent-configs.js's statIfPresent rather than imported,
138
+ * per this project's one-small-self-contained-file-per-source convention
139
+ * (see cursor.js's own docstring for the same point). Absence (ENOENT/
140
+ * ENOTDIR) is the normal, expected case for a tool the user doesn't use
141
+ * and yields nothing; anything else that stops the path resolving (a
142
+ * dangling symlink, a permission error) is a broken entry, not silent
143
+ * absence — the same never-a-false-all-clear reasoning as every other
144
+ * source here.
145
+ */
146
+ function* statIfPresent(p) {
147
+ let lst;
148
+ try { lst = fs.lstatSync(p); }
149
+ catch (err) {
150
+ if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return;
151
+ yield { file: p, broken: true };
152
+ return;
153
+ }
154
+
155
+ if (lst.isSymbolicLink()) {
156
+ try {
157
+ const st = fs.statSync(p);
158
+ if (!st.isFile()) { yield { file: p, broken: true }; return; }
159
+ yield { file: p, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
160
+ } catch {
161
+ yield { file: p, broken: true };
162
+ }
163
+ return;
164
+ }
165
+
166
+ if (!lst.isFile()) return;
167
+ yield { file: p, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
168
+ }
169
+
170
+ /**
171
+ * True when any candidate history file actually exists. Unlike
172
+ * agent-configs.js (which gates on a per-tool ROOT directory so an
173
+ * installed-but-empty tool still shows as checked), none of the files
174
+ * here have a natural "installed" signal separate from the file's own
175
+ * existence — there is no `~/.bash/` directory to check instead. A
176
+ * machine with none of these files present correctly doesn't list this
177
+ * source at all, the same as a brand-new machine with no shell history
178
+ * yet would have nothing meaningful to report either way.
179
+ */
180
+ function available() {
181
+ for (const p of candidatePaths()) {
182
+ for (const _ of statIfPresent(p)) return true;
183
+ }
184
+ return false;
185
+ }
186
+
187
+ /**
188
+ * Yield { file, mtimeMs, sizeBytes, broken } for every candidate present.
189
+ */
190
+ function* files() {
191
+ for (const p of candidatePaths()) yield* statIfPresent(p);
192
+ }
193
+
194
+ // Real observations on this project's own build machine: ~40KB
195
+ // (~.bash_history, years of use) and ~6.6KB (~.python_history). 256MB is a
196
+ // generous, uncalibrated backstop against a corrupted or pathological file
197
+ // (the same caveat cursor.js states for its own size bound), not a measured
198
+ // ceiling — a file over it is surfaced as "too-large", never silently
199
+ // skipped.
200
+ const MAX_BYTES = 256 * 1024 * 1024;
201
+ const READ_TIMEOUT_MS = 60_000;
202
+
203
+ /**
204
+ * Read one history file as an array of raw text lines. Identical streaming
205
+ * shape (readline/promises, MAX_BYTES cap, READ_TIMEOUT_MS watchdog,
206
+ * partial-read lines kept rather than discarded) to every other source
207
+ * here — see claude-code.js's readLines() docstring for the full
208
+ * reasoning, all of which applies unchanged since this is plain
209
+ * line-delimited UTF-8 text on disk (see the module docstring's FORMAT
210
+ * section for why no per-tool parsing is needed first).
211
+ */
212
+ async function readLines(file) {
213
+ let stat;
214
+ try { stat = fs.statSync(file); }
215
+ catch { return { lines: [], status: "failed", bytesRead: 0 }; }
216
+ if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
217
+
218
+ const lines = [];
219
+ let bytesRead = 0;
220
+ const stream = fs.createReadStream(file, { encoding: "utf-8" });
221
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
222
+ const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
223
+
224
+ try {
225
+ for await (const line of rl) {
226
+ lines.push(line);
227
+ bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
228
+ }
229
+ return { lines, status: "complete", bytesRead };
230
+ } catch {
231
+ // Lines read before the failure are real content and may hold a real
232
+ // secret -- an honest "partial" beats a silent false negative.
233
+ return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
234
+ } finally {
235
+ clearTimeout(timer);
236
+ rl.close();
237
+ stream.destroy();
238
+ }
239
+ }
240
+
241
+ module.exports = { id, label, available, files, readLines };
package/src/watch.js CHANGED
@@ -267,14 +267,14 @@ function makeSyntheticSource(realId, batchesByFile) {
267
267
  * `verify` is always forced off here: seeding a dedup cache must never be
268
268
  * the reason a live vendor API gets hit.
269
269
  */
270
- async function baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor, includePii) {
270
+ async function baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor, includePii, includeInjection) {
271
271
  const batch = await readWholeFile(source, file, sizeBytes, mtimeMs);
272
272
  if (!batch) return;
273
273
  let result;
274
274
  try {
275
275
  result = await scan({
276
276
  sources: [makeSyntheticSource(sourceId, new Map([[file, batch]]))],
277
- includeNoisy, includeSuppressed, verify: false, noColor, includePii,
277
+ includeNoisy, includeSuppressed, verify: false, noColor, includePii, includeInjection,
278
278
  });
279
279
  } catch {
280
280
  return; // best-effort: a failure here just leaves this file's dedup
@@ -300,7 +300,7 @@ async function baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, in
300
300
  * `dismiss` takes effect without a restart.
301
301
  */
302
302
  async function sweepOnce({ sources, tracked, seen, ledger, options, emit }) {
303
- const { includeNoisy, includeSuppressed, verify, noColor, includePii } = options || {};
303
+ const { includeNoisy, includeSuppressed, verify, noColor, includePii, includeInjection } = options || {};
304
304
  let loud = 0;
305
305
  let quiet = 0;
306
306
  let suppressedByLedger = 0;
@@ -364,7 +364,7 @@ async function sweepOnce({ sources, tracked, seen, ledger, options, emit }) {
364
364
  contentHash: tailable ? null : wholeFileHash(file),
365
365
  });
366
366
  if (!tailable) {
367
- await baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor, includePii);
367
+ await baselineSeed(source, sourceId, file, sizeBytes, mtimeMs, seen, includeNoisy, includeSuppressed, noColor, includePii, includeInjection);
368
368
  }
369
369
  continue;
370
370
  }
@@ -433,7 +433,7 @@ async function sweepOnce({ sources, tracked, seen, ledger, options, emit }) {
433
433
  try {
434
434
  result = await scan({
435
435
  sources: [makeSyntheticSource(sourceId, batchesByFile)],
436
- includeNoisy, includeSuppressed, verify, noColor, includePii,
436
+ includeNoisy, includeSuppressed, verify, noColor, includePii, includeInjection,
437
437
  });
438
438
  } catch (err) {
439
439
  emit({ type: "watch-error", at: new Date(), source: sourceId, detail: "scan failed: " + (err && err.message) });