proton-mail-bridge-client 2.16.2 → 2.17.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.
Files changed (245) hide show
  1. package/README.md +149 -12
  2. package/dist/cli.d.ts +16 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +178 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/config-env.d.ts +2 -1
  7. package/dist/config-env.d.ts.map +1 -1
  8. package/dist/config-env.js +24 -0
  9. package/dist/config-env.js.map +1 -1
  10. package/dist/index.d.ts +5 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +180 -11
  13. package/dist/index.js.map +1 -1
  14. package/dist/services/account-manager.d.ts +7 -0
  15. package/dist/services/account-manager.d.ts.map +1 -1
  16. package/dist/services/account-manager.js +59 -0
  17. package/dist/services/account-manager.js.map +1 -1
  18. package/dist/services/audit-service.d.ts +20 -0
  19. package/dist/services/audit-service.d.ts.map +1 -1
  20. package/dist/services/audit-service.js +266 -9
  21. package/dist/services/audit-service.js.map +1 -1
  22. package/dist/services/autoresponder-service.d.ts +35 -6
  23. package/dist/services/autoresponder-service.d.ts.map +1 -1
  24. package/dist/services/autoresponder-service.js +223 -108
  25. package/dist/services/autoresponder-service.js.map +1 -1
  26. package/dist/services/brief-service.d.ts +7 -1
  27. package/dist/services/brief-service.d.ts.map +1 -1
  28. package/dist/services/brief-service.js +19 -5
  29. package/dist/services/brief-service.js.map +1 -1
  30. package/dist/services/category-service.d.ts +91 -0
  31. package/dist/services/category-service.d.ts.map +1 -0
  32. package/dist/services/category-service.js +320 -0
  33. package/dist/services/category-service.js.map +1 -0
  34. package/dist/services/commitment-service.d.ts.map +1 -1
  35. package/dist/services/commitment-service.js +9 -3
  36. package/dist/services/commitment-service.js.map +1 -1
  37. package/dist/services/contact-service.d.ts +60 -0
  38. package/dist/services/contact-service.d.ts.map +1 -0
  39. package/dist/services/contact-service.js +248 -0
  40. package/dist/services/contact-service.js.map +1 -0
  41. package/dist/services/delivery-queue-service.d.ts +4 -0
  42. package/dist/services/delivery-queue-service.d.ts.map +1 -1
  43. package/dist/services/delivery-queue-service.js +12 -2
  44. package/dist/services/delivery-queue-service.js.map +1 -1
  45. package/dist/services/draft-store-service.d.ts.map +1 -1
  46. package/dist/services/draft-store-service.js +3 -2
  47. package/dist/services/draft-store-service.js.map +1 -1
  48. package/dist/services/embedding-service.d.ts +68 -0
  49. package/dist/services/embedding-service.d.ts.map +1 -0
  50. package/dist/services/embedding-service.js +465 -0
  51. package/dist/services/embedding-service.js.map +1 -0
  52. package/dist/services/event-log-service.d.ts +67 -0
  53. package/dist/services/event-log-service.d.ts.map +1 -0
  54. package/dist/services/event-log-service.js +280 -0
  55. package/dist/services/event-log-service.js.map +1 -0
  56. package/dist/services/grant-service.d.ts +84 -0
  57. package/dist/services/grant-service.d.ts.map +1 -0
  58. package/dist/services/grant-service.js +214 -0
  59. package/dist/services/grant-service.js.map +1 -0
  60. package/dist/services/local-index-service.d.ts +18 -1
  61. package/dist/services/local-index-service.d.ts.map +1 -1
  62. package/dist/services/local-index-service.js +54 -4
  63. package/dist/services/local-index-service.js.map +1 -1
  64. package/dist/services/reply-reminder-service.d.ts +4 -0
  65. package/dist/services/reply-reminder-service.d.ts.map +1 -1
  66. package/dist/services/reply-reminder-service.js +22 -2
  67. package/dist/services/reply-reminder-service.js.map +1 -1
  68. package/dist/services/rules-service.d.ts +9 -1
  69. package/dist/services/rules-service.d.ts.map +1 -1
  70. package/dist/services/rules-service.js +8 -3
  71. package/dist/services/rules-service.js.map +1 -1
  72. package/dist/services/sieve-export.d.ts +19 -0
  73. package/dist/services/sieve-export.d.ts.map +1 -0
  74. package/dist/services/sieve-export.js +192 -0
  75. package/dist/services/sieve-export.js.map +1 -0
  76. package/dist/services/simple-imap-service.d.ts +13 -0
  77. package/dist/services/simple-imap-service.d.ts.map +1 -1
  78. package/dist/services/simple-imap-service.js +142 -18
  79. package/dist/services/simple-imap-service.js.map +1 -1
  80. package/dist/services/smtp-service.d.ts +1 -0
  81. package/dist/services/smtp-service.d.ts.map +1 -1
  82. package/dist/services/smtp-service.js +20 -0
  83. package/dist/services/smtp-service.js.map +1 -1
  84. package/dist/services/snooze-service.d.ts +4 -0
  85. package/dist/services/snooze-service.d.ts.map +1 -1
  86. package/dist/services/snooze-service.js +12 -3
  87. package/dist/services/snooze-service.js.map +1 -1
  88. package/dist/services/template-service.d.ts.map +1 -1
  89. package/dist/services/template-service.js +3 -2
  90. package/dist/services/template-service.js.map +1 -1
  91. package/dist/services/triage-plan-service.d.ts +10 -2
  92. package/dist/services/triage-plan-service.d.ts.map +1 -1
  93. package/dist/services/triage-plan-service.js +23 -3
  94. package/dist/services/triage-plan-service.js.map +1 -1
  95. package/dist/tools/attachments.js +1 -1
  96. package/dist/tools/attachments.js.map +1 -1
  97. package/dist/tools/brief.d.ts.map +1 -1
  98. package/dist/tools/brief.js +14 -4
  99. package/dist/tools/brief.js.map +1 -1
  100. package/dist/tools/categories.d.ts +4 -0
  101. package/dist/tools/categories.d.ts.map +1 -0
  102. package/dist/tools/categories.js +269 -0
  103. package/dist/tools/categories.js.map +1 -0
  104. package/dist/tools/commitments.d.ts.map +1 -1
  105. package/dist/tools/commitments.js +6 -2
  106. package/dist/tools/commitments.js.map +1 -1
  107. package/dist/tools/contacts.d.ts +6 -0
  108. package/dist/tools/contacts.d.ts.map +1 -0
  109. package/dist/tools/contacts.js +295 -0
  110. package/dist/tools/contacts.js.map +1 -0
  111. package/dist/tools/context.d.ts +4 -0
  112. package/dist/tools/context.d.ts.map +1 -1
  113. package/dist/tools/context.js +3 -0
  114. package/dist/tools/context.js.map +1 -1
  115. package/dist/tools/definitions.d.ts +333 -5
  116. package/dist/tools/definitions.d.ts.map +1 -1
  117. package/dist/tools/definitions.js +169 -5
  118. package/dist/tools/definitions.js.map +1 -1
  119. package/dist/tools/delegation.d.ts +44 -0
  120. package/dist/tools/delegation.d.ts.map +1 -0
  121. package/dist/tools/delegation.js +180 -0
  122. package/dist/tools/delegation.js.map +1 -0
  123. package/dist/tools/diagnostics.d.ts.map +1 -1
  124. package/dist/tools/diagnostics.js +6 -0
  125. package/dist/tools/diagnostics.js.map +1 -1
  126. package/dist/tools/drafts-support.d.ts +2 -1
  127. package/dist/tools/drafts-support.d.ts.map +1 -1
  128. package/dist/tools/drafts-support.js +14 -6
  129. package/dist/tools/drafts-support.js.map +1 -1
  130. package/dist/tools/drafts.d.ts.map +1 -1
  131. package/dist/tools/drafts.js +22 -1
  132. package/dist/tools/drafts.js.map +1 -1
  133. package/dist/tools/events.d.ts +47 -0
  134. package/dist/tools/events.d.ts.map +1 -0
  135. package/dist/tools/events.js +240 -0
  136. package/dist/tools/events.js.map +1 -0
  137. package/dist/tools/reading.d.ts.map +1 -1
  138. package/dist/tools/reading.js +125 -4
  139. package/dist/tools/reading.js.map +1 -1
  140. package/dist/tools/resources.d.ts +4 -0
  141. package/dist/tools/resources.d.ts.map +1 -1
  142. package/dist/tools/resources.js +9 -0
  143. package/dist/tools/resources.js.map +1 -1
  144. package/dist/tools/rules-runner.d.ts.map +1 -1
  145. package/dist/tools/rules-runner.js +1 -0
  146. package/dist/tools/rules-runner.js.map +1 -1
  147. package/dist/tools/rules.d.ts +9 -0
  148. package/dist/tools/rules.d.ts.map +1 -1
  149. package/dist/tools/rules.js +50 -19
  150. package/dist/tools/rules.js.map +1 -1
  151. package/dist/tools/sending.d.ts.map +1 -1
  152. package/dist/tools/sending.js +4 -0
  153. package/dist/tools/sending.js.map +1 -1
  154. package/dist/tools/style.d.ts +2 -0
  155. package/dist/tools/style.d.ts.map +1 -1
  156. package/dist/tools/style.js +3 -3
  157. package/dist/tools/style.js.map +1 -1
  158. package/dist/tools/templates-and-reminders.d.ts +3 -0
  159. package/dist/tools/templates-and-reminders.d.ts.map +1 -1
  160. package/dist/tools/templates-and-reminders.js +31 -17
  161. package/dist/tools/templates-and-reminders.js.map +1 -1
  162. package/dist/tools/triage.d.ts.map +1 -1
  163. package/dist/tools/triage.js +33 -11
  164. package/dist/tools/triage.js.map +1 -1
  165. package/dist/types/index.d.ts +23 -0
  166. package/dist/types/index.d.ts.map +1 -1
  167. package/dist/utils/account-identity.d.ts.map +1 -1
  168. package/dist/utils/account-identity.js +3 -2
  169. package/dist/utils/account-identity.js.map +1 -1
  170. package/dist/utils/approval.d.ts +133 -0
  171. package/dist/utils/approval.d.ts.map +1 -0
  172. package/dist/utils/approval.js +507 -0
  173. package/dist/utils/approval.js.map +1 -0
  174. package/dist/utils/atomic-write.d.ts +1 -1
  175. package/dist/utils/atomic-write.d.ts.map +1 -1
  176. package/dist/utils/atomic-write.js +10 -3
  177. package/dist/utils/atomic-write.js.map +1 -1
  178. package/dist/utils/commitment-phrases.d.ts.map +1 -1
  179. package/dist/utils/commitment-phrases.js +31 -18
  180. package/dist/utils/commitment-phrases.js.map +1 -1
  181. package/dist/utils/contact-text.d.ts +8 -0
  182. package/dist/utils/contact-text.d.ts.map +1 -0
  183. package/dist/utils/contact-text.js +24 -0
  184. package/dist/utils/contact-text.js.map +1 -0
  185. package/dist/utils/delegation.d.ts +17 -0
  186. package/dist/utils/delegation.d.ts.map +1 -0
  187. package/dist/utils/delegation.js +72 -0
  188. package/dist/utils/delegation.js.map +1 -0
  189. package/dist/utils/file-lock.d.ts.map +1 -1
  190. package/dist/utils/file-lock.js +5 -4
  191. package/dist/utils/file-lock.js.map +1 -1
  192. package/dist/utils/mail-security.d.ts +9 -1
  193. package/dist/utils/mail-security.d.ts.map +1 -1
  194. package/dist/utils/mail-security.js +444 -62
  195. package/dist/utils/mail-security.js.map +1 -1
  196. package/dist/utils/ocr.d.ts +24 -0
  197. package/dist/utils/ocr.d.ts.map +1 -0
  198. package/dist/utils/ocr.js +109 -0
  199. package/dist/utils/ocr.js.map +1 -0
  200. package/dist/utils/office-text-worker.d.ts +2 -0
  201. package/dist/utils/office-text-worker.d.ts.map +1 -0
  202. package/dist/utils/office-text-worker.js +17 -0
  203. package/dist/utils/office-text-worker.js.map +1 -0
  204. package/dist/utils/office-text.d.ts +32 -0
  205. package/dist/utils/office-text.d.ts.map +1 -0
  206. package/dist/utils/office-text.js +540 -0
  207. package/dist/utils/office-text.js.map +1 -0
  208. package/dist/utils/pdf-text-worker.js +2 -2
  209. package/dist/utils/pdf-text-worker.js.map +1 -1
  210. package/dist/utils/pdf-text.d.ts +10 -0
  211. package/dist/utils/pdf-text.d.ts.map +1 -1
  212. package/dist/utils/pdf-text.js +49 -9
  213. package/dist/utils/pdf-text.js.map +1 -1
  214. package/dist/utils/png.d.ts +2 -0
  215. package/dist/utils/png.d.ts.map +1 -0
  216. package/dist/utils/png.js +26 -0
  217. package/dist/utils/png.js.map +1 -0
  218. package/dist/utils/regular-file.d.ts +9 -0
  219. package/dist/utils/regular-file.d.ts.map +1 -0
  220. package/dist/utils/regular-file.js +34 -0
  221. package/dist/utils/regular-file.js.map +1 -0
  222. package/dist/utils/runtime-policy.d.ts.map +1 -1
  223. package/dist/utils/runtime-policy.js +2 -0
  224. package/dist/utils/runtime-policy.js.map +1 -1
  225. package/dist/utils/safe-write.d.ts +8 -0
  226. package/dist/utils/safe-write.d.ts.map +1 -0
  227. package/dist/utils/safe-write.js +46 -0
  228. package/dist/utils/safe-write.js.map +1 -0
  229. package/dist/utils/server-state-paths.d.ts +12 -0
  230. package/dist/utils/server-state-paths.d.ts.map +1 -0
  231. package/dist/utils/server-state-paths.js +69 -0
  232. package/dist/utils/server-state-paths.js.map +1 -0
  233. package/dist/utils/style-profile.d.ts +18 -0
  234. package/dist/utils/style-profile.d.ts.map +1 -1
  235. package/dist/utils/style-profile.js +63 -14
  236. package/dist/utils/style-profile.js.map +1 -1
  237. package/dist/utils/vcard.d.ts +27 -0
  238. package/dist/utils/vcard.d.ts.map +1 -0
  239. package/dist/utils/vcard.js +141 -0
  240. package/dist/utils/vcard.js.map +1 -0
  241. package/dist/utils/zip-reader.d.ts +16 -0
  242. package/dist/utils/zip-reader.d.ts.map +1 -0
  243. package/dist/utils/zip-reader.js +108 -0
  244. package/dist/utils/zip-reader.js.map +1 -0
  245. package/package.json +3 -3
@@ -1,3 +1,4 @@
1
+ import { normalizeBoolean, optionalBoolean } from "./helpers.js";
1
2
  // Phrases that address an assistant instead of the reader. English, Polish and German, the languages of this mailbox.
2
3
  const INSTRUCTION_PATTERNS = [
3
4
  [/\b(ignore|disregard|forget)\b[^.\n]{0,40}\b(previous|prior|above|earlier|all)\b[^.\n]{0,30}\b(instructions?|prompts?|rules)\b/i, "tells an AI to ignore its instructions"],
@@ -10,7 +11,63 @@ const INSTRUCTION_PATTERNS = [
10
11
  [/\b(delete|remove|trash)\b[^.\n]{0,30}\b(all|every)\b[^.\n]{0,30}\b(emails?|messages?)\b/i, "asks an AI to delete mail"],
11
12
  ];
12
13
  const ZERO_WIDTH = /[​-‏⁠]/g;
13
- const HIDDEN_STYLE = /<([a-z0-9]+)\b[^>]*style\s*=\s*"[^"]*(?:display\s*:\s*none|visibility\s*:\s*hidden|font-size\s*:\s*0(?:px|pt|em)?\b|opacity\s*:\s*0(?:\.0+)?\b|color\s*:\s*(?:#fff(?:fff)?|white)\b[^"]*background[^"]*(?:#fff(?:fff)?|white))[^"]*"[^>]*>([\s\S]*?)<\/\1>/gi;
14
+ // HTML is scanned tag by tag, with every step bounded, never with a pattern that can backtrack over the whole
15
+ // document: one crafted message must not be able to stall the server (a pattern over unclosed <a tags took seconds).
16
+ const MAX_HTML_SCANNED = 200_000;
17
+ const MAX_TAG_LENGTH = 1_500;
18
+ const MAX_BLOCK_LENGTH = 5_000;
19
+ const MAX_HIDDEN_BLOCKS = 50;
20
+ const MAX_ANCHORS = 300;
21
+ const HIDING_STYLE = /display\s*:\s*none|visibility\s*:\s*hidden|font-size\s*:\s*0(?:px|pt|em|rem|%)?(?![\d.])|opacity\s*:\s*0(?:\.0+)?(?![\d.])|(?:left|top|text-indent|margin-left)\s*:\s*-\d{3,}|max-height\s*:\s*0(?:px)?(?![\d.])/i;
22
+ const WHITE_ON_WHITE = /(?:^|[;\s])color\s*:\s*(?:#fff(?:fff)?|white)\b[\s\S]{0,300}?background(?:-color)?\s*:\s*(?:#fff(?:fff)?|white)\b/i;
23
+ const VOID_TAGS = new Set(["img", "br", "hr", "input", "meta", "link", "area", "base", "col", "embed", "source", "track", "wbr"]);
24
+ function styleOf(attributes) {
25
+ const match = /style\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attributes);
26
+ return match?.[1] ?? match?.[2] ?? "";
27
+ }
28
+ /** The text inside elements a reader cannot see (hidden by style or the hidden attribute). */
29
+ function hiddenTexts(html) {
30
+ const lower = html.toLowerCase();
31
+ const found = [];
32
+ for (const tag of html.matchAll(new RegExp(`<([a-z][a-z0-9]*)(\\s[^<>]{0,${MAX_TAG_LENGTH}})?>`, "gi"))) {
33
+ if (found.length >= MAX_HIDDEN_BLOCKS)
34
+ break;
35
+ const name = tag[1].toLowerCase();
36
+ const attributes = tag[2] ?? "";
37
+ if (VOID_TAGS.has(name) || !attributes)
38
+ continue;
39
+ const style = styleOf(attributes);
40
+ const hides = HIDING_STYLE.test(style) || WHITE_ON_WHITE.test(style) || /(?:^|\s)hidden(?:\s|=|$)/i.test(attributes);
41
+ if (!hides)
42
+ continue;
43
+ const start = (tag.index ?? 0) + tag[0].length;
44
+ const close = lower.indexOf(`</${name}`, start);
45
+ const end = close === -1 ? Math.min(html.length, start + MAX_BLOCK_LENGTH) : Math.min(close, start + MAX_BLOCK_LENGTH);
46
+ found.push(html.slice(start, end).replace(/<[^>]*>/g, " ").replace(/[ \t\n\r\f]+/g, " ").trim());
47
+ }
48
+ return found;
49
+ }
50
+ /** The first link whose visible text names another host than the one it goes to. */
51
+ function mismatchedLink(html) {
52
+ const lower = html.toLowerCase();
53
+ let seen = 0;
54
+ for (const tag of html.matchAll(new RegExp(`<a\\s([^<>]{0,${MAX_TAG_LENGTH}})>`, "gi"))) {
55
+ if (++seen > MAX_ANCHORS)
56
+ break;
57
+ const href = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(tag[1]);
58
+ const target = href?.[1] ?? href?.[2];
59
+ if (!target)
60
+ continue;
61
+ const start = (tag.index ?? 0) + tag[0].length;
62
+ const close = lower.indexOf("</a", start);
63
+ const text = html.slice(start, close === -1 ? start + 500 : Math.min(close, start + 500)).replace(/<[^>]*>/g, "").trim();
64
+ const shownHost = text.match(/^(?:https?:\/\/)?([\w.-]+\.[a-z]{2,})(?:[/\s]|$)/i)?.[1]?.toLowerCase();
65
+ const realHost = hostOf(target);
66
+ if (shownHost && realHost && baseDomain(shownHost) !== baseDomain(realHost))
67
+ return { shown: shownHost, real: realHost };
68
+ }
69
+ return undefined;
70
+ }
14
71
  function domainOf(address) {
15
72
  return (address?.split("@")[1] ?? "").trim().toLowerCase();
16
73
  }
@@ -26,12 +83,53 @@ function hostOf(url) {
26
83
  return "";
27
84
  }
28
85
  }
86
+ // Control and invisible characters that padding or splitting a word can use to slip past a pattern.
87
+ const HIDDEN_CHARS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u00ad\u200b-\u200f\u2028-\u202e\u2060-\u2064\ufeff]/g;
88
+ const NEEDS_NORMALISING = /[^\t\n\r\x20-\x7e]/;
89
+ const SPACE_RUN = /[ \t]{2,}/g;
90
+ const normalised = (text) => text.normalize("NFKC").replace(/[ \t]{2,}/g, " ");
91
+ // The text with the hidden characters taken out and with them turned into spaces, each in NFKC form: a word split by one
92
+ // reads whole in the first, two words run together by one stay apart in the second, and look-alike forms (fullwidth
93
+ // letters, no-break spaces) read as plain ones, and runs of spaces count as one. Plain text with single spaces is matched as it is. Only the
94
+ // instruction patterns run on these, and they are bounded, so the cost stays linear.
95
+ function normalisedVariants(text) {
96
+ // Plain printable ASCII has no hidden characters and nothing for NFKC to change: only a run of spaces can differ.
97
+ if (!NEEDS_NORMALISING.test(text))
98
+ return [text.includes(" ") || text.includes("\t") ? text.replace(SPACE_RUN, " ") : text];
99
+ const variants = new Set([normalised(text), normalised(text.replace(HIDDEN_CHARS, "")), normalised(text.replace(HIDDEN_CHARS, " "))]);
100
+ return [...variants];
101
+ }
29
102
  function matchesInstruction(text) {
30
- for (const [pattern, reason] of INSTRUCTION_PATTERNS)
31
- if (pattern.test(text))
32
- return reason;
103
+ for (const variant of normalisedVariants(text)) {
104
+ for (const [pattern, reason] of INSTRUCTION_PATTERNS)
105
+ if (pattern.test(variant))
106
+ return reason;
107
+ }
33
108
  return undefined;
34
109
  }
110
+ // Adversarial text with hiding characters costs up to about 1.3 ms per KB (normalisation variants times patterns), so one
111
+ // text is not matched whole past MAX_WHOLE_TEXT: its two ends and a few evenly spaced windows between them are, each
112
+ // normalised on its own, and the assessment says it was only partly read.
113
+ const MAX_WHOLE_TEXT = 512_000;
114
+ const END_WINDOW = 48_000;
115
+ const MIDDLE_WINDOWS = 8;
116
+ const MIDDLE_WINDOW = 12_000;
117
+ function matchesInstructionBounded(text) {
118
+ if (text.length <= MAX_WHOLE_TEXT)
119
+ return { reason: matchesInstruction(text), partial: false };
120
+ const span = text.length - 2 * END_WINDOW;
121
+ const windows = [text.slice(0, END_WINDOW), text.slice(-END_WINDOW)];
122
+ for (let i = 0; i < MIDDLE_WINDOWS; i += 1) {
123
+ const start = END_WINDOW + Math.floor(((i + 0.5) * span) / MIDDLE_WINDOWS) - MIDDLE_WINDOW / 2;
124
+ windows.push(text.slice(start, start + MIDDLE_WINDOW));
125
+ }
126
+ for (const window of windows) {
127
+ const reason = matchesInstruction(window);
128
+ if (reason)
129
+ return { reason, partial: true };
130
+ }
131
+ return { partial: true };
132
+ }
35
133
  function headerText(headers, name) {
36
134
  const value = headers?.[name] ?? headers?.[name.toLowerCase()];
37
135
  if (Array.isArray(value))
@@ -42,22 +140,30 @@ export function assessMail(input) {
42
140
  const high = [];
43
141
  const caution = [];
44
142
  const visible = `${input.subject ?? ""}\n${input.text ?? ""}`;
45
- const instruction = matchesInstruction(visible);
143
+ const { reason: instruction, partial } = matchesInstructionBounded(visible);
46
144
  if (instruction)
47
145
  high.push(`The message ${instruction}.`);
48
- const html = typeof input.html === "string" ? input.html : "";
49
- for (const match of html.matchAll(HIDDEN_STYLE)) {
50
- const hidden = (match[2] ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
146
+ // Same fail-closed wording as the result scan: text that was not read in full may hide an instruction.
147
+ else if (partial)
148
+ high.push(`oversized-unscanned: this message is ${visible.length} characters long, so only parts of it were checked for instructions to an AI (partially assessed).`);
149
+ const html = typeof input.html === "string" ? input.html.slice(0, MAX_HTML_SCANNED) : "";
150
+ let hiddenSeen = false;
151
+ for (const hidden of hiddenTexts(html)) {
51
152
  if (hidden.length < 20)
52
153
  continue;
53
154
  const hiddenInstruction = matchesInstruction(hidden);
54
- if (hiddenInstruction)
155
+ if (hiddenInstruction) {
55
156
  high.push(`Hidden text in the message ${hiddenInstruction}.`);
56
- else
57
- caution.push("The message has text the reader cannot see (hidden by its markup).");
58
- break;
157
+ break;
158
+ }
159
+ hiddenSeen = true;
59
160
  }
60
- const zeroWidth = (visible.match(ZERO_WIDTH) ?? []).length;
161
+ if (hiddenSeen && high.length === 0)
162
+ caution.push("The message has text the reader cannot see (hidden by its markup).");
163
+ let zeroWidth = 0;
164
+ for (const _ of visible.matchAll(ZERO_WIDTH))
165
+ if (++zeroWidth >= 5)
166
+ break;
61
167
  if (zeroWidth >= 5 && input.automated !== true)
62
168
  caution.push("The text has invisible characters (zero-width), which can hide wording from the reader.");
63
169
  const fromAddress = input.from?.[0]?.address;
@@ -76,15 +182,9 @@ export function assessMail(input) {
76
182
  const auth = headerText(input.headers, "authentication-results").toLowerCase();
77
183
  if (/\b(dkim|spf|dmarc)=fail\b/.test(auth))
78
184
  caution.push("The sender failed an authentication check (SPF, DKIM or DMARC).");
79
- for (const link of html.matchAll(/<a\b[^>]*href\s*=\s*"([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi)) {
80
- const shown = link[2].replace(/<[^>]*>/g, "").trim();
81
- const shownHost = shown.match(/^(?:https?:\/\/)?([\w.-]+\.[a-z]{2,})(?:[/\s]|$)/i)?.[1]?.toLowerCase();
82
- const realHost = hostOf(link[1]);
83
- if (shownHost && realHost && baseDomain(shownHost) !== baseDomain(realHost)) {
84
- caution.push(`A link reads ${shownHost} but goes to ${realHost}.`);
85
- break;
86
- }
87
- }
185
+ const link = mismatchedLink(html);
186
+ if (link)
187
+ caution.push(`A link reads ${link.shown} but goes to ${link.real}.`);
88
188
  const reasons = [...high, ...caution];
89
189
  return { level: high.length ? "high" : caution.length ? "caution" : "ok", reasons };
90
190
  }
@@ -103,42 +203,302 @@ export class MailTaint {
103
203
  // Tools that send mail or change what happens to it without a person looking at each case.
104
204
  export const TAINT_GATED_TOOLS = new Set([
105
205
  "send_email", "reply_to_email", "reply_all_email", "forward_email", "send_draft", "schedule_draft", "respond_to_invite",
106
- "unsubscribe_sender", "delete_email", "delete_thread", "bulk_delete", "empty_folder", "set_rule", "set_autoresponder",
206
+ "unsubscribe_sender", "delete_email", "delete_thread", "bulk_delete", "empty_folder", "delete_folder", "set_rule", "set_autoresponder",
207
+ // A vip tag changes the autoresponder text for that sender.
208
+ "set_contact", "import_contacts",
107
209
  ]);
210
+ // Tools that only act for some arguments: apply_rules runs its rules when dryRun is false (it previews otherwise), and the
211
+ // batch tools are gated for the actions that destroy.
212
+ const DESTRUCTIVE_ACTIONS = new Set(["delete", "trash"]);
213
+ /**
214
+ * Whether this call is one the confirmation gate applies to after a high-risk message was read. A preview or a switch-off
215
+ * passes, but only through an argument the tool really declares: an undeclared `dryRun` is ignored by the tool, and honouring
216
+ * it here would let a call skip the gate and still act. Each flag is read with the parser the handlers use (so "false",
217
+ * "no", 0 and " FALSE " mean what they mean there), and the action is trimmed the way requireString trims it.
218
+ */
219
+ export function isGatedAction(name, args, declared) {
220
+ if (name === "apply_rules")
221
+ return normalizeBoolean(args.dryRun, true) === false;
222
+ if (name === "batch_email_action" || name === "apply_thread_action") {
223
+ return typeof args.action === "string" && DESTRUCTIVE_ACTIONS.has(args.action.trim()) && !(declared.has("dryRun") && normalizeBoolean(args.dryRun, false));
224
+ }
225
+ if (!TAINT_GATED_TOOLS.has(name))
226
+ return false;
227
+ if (declared.has("dryRun") && normalizeBoolean(args.dryRun, false))
228
+ return false;
229
+ if (declared.has("enabled") && optionalBoolean(args.enabled) === false)
230
+ return false;
231
+ if (declared.has("active") && optionalBoolean(args.active) === false)
232
+ return false;
233
+ return true;
234
+ }
108
235
  const ID_KEYS = ["id", "emailId", "primaryEmailId", "latestEmailId"];
109
236
  const MAX_SCAN_DEPTH = 7;
237
+ // A result is walked in full: every string in it is text to check, one node at a time (a list of ordinary previews is
238
+ // many small scans, not one big one). The work is capped two ways, so padding cannot buy a stall: a total of characters
239
+ // and a wall-clock deadline. Whatever the cap left unchecked taints the session ("oversized-unscanned") with the count in
240
+ // the reason, rather than the result passing unchecked. Clean text costs about 8 ms per MB, adversarial text up to about
241
+ // 0.3 s per MB per normalisation variant, so the deadline, not the character budget, is the CPU bound.
242
+ const SCAN_BUDGET = 24_000_000;
243
+ const SCAN_DEADLINE_MS = 1_000;
244
+ const SCAN_CHUNK = 32_768;
245
+ const SCAN_OVERLAP = 512;
246
+ // How much of the end of one node and the start of the next is read together, for an instruction split between them.
247
+ const SEAM = 250;
248
+ const MAX_FLAT_NODES = 1_000_000;
249
+ // Where the walk does not parse: a result that is not JSON or is too large to parse keeps a head and a tail window.
110
250
  const MAX_SCAN_CHARS = 2_000_000;
111
- // Walks a tool result and marks every message-like node (an id and some text: a body, a preview, an attachment's text)
112
- // that reads like an instruction to an AI. The session is tainted by it and the node gets a `risk` note. Only the
113
- // instruction patterns are used here: a list of previews would be too noisy with the weaker signals. Returns whether
114
- // anything was marked.
115
- export function annotateHighRisk(value, taint, depth = 0) {
116
- if (depth > MAX_SCAN_DEPTH || value === null || typeof value !== "object")
117
- return false;
251
+ const MAX_PARSE_CHARS = 32_000_000;
252
+ const SCAN_WINDOW = 100_000;
253
+ // Fields the server fills itself (ids, dates, counts, hashes). Skipped only while short and only if the value looks like what the
254
+ // key promises: a header-derived string (a Message-ID, a Date header, a Content-Type with parameters, a name used as an id) is text.
255
+ const DATE_KEYS = ["date", "receivedAt", "sentAt", "internalDate", "createdAt", "updatedAt", "expiresAt"];
256
+ const TOKEN_KEYS = [...ID_KEYS, "uid", "uids", "flags", "count", "total", "size", "hash", "sha256", "checksum", "account"];
257
+ const TOKEN_VALUE = /^[\w.:@<>+=~%/!$*\\-]+$/;
258
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
259
+ const RFC_DATE = /^(?:[A-Za-z]{3}, )?\d{1,2} [A-Za-z]{3} \d{2,4} \d{2}:\d{2}(?::\d{2})?(?: (?:[+-]\d{4}|[A-Z]{1,5}))?$/;
260
+ const MIME_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i;
261
+ const MACHINE_VALUE = {
262
+ ...Object.fromEntries(TOKEN_KEYS.map((key) => [key, (value) => TOKEN_VALUE.test(value)])),
263
+ ...Object.fromEntries(DATE_KEYS.map((key) => [key, (value) => ISO_DATE.test(value) || RFC_DATE.test(value)])),
264
+ mimeType: (value) => MIME_TYPE.test(value),
265
+ };
266
+ const MACHINE_VALUE_MAX = 128;
267
+ const UNIDENTIFIED = "unidentified-result";
268
+ // Notes this module put on nodes, so a `risk` key that came with the data is never mistaken for one.
269
+ const RISK_NOTES = new WeakSet();
270
+ const newContext = (deadline) => ({ budget: SCAN_BUDGET, deadline, unscanned: 0, flatNodes: 0, cut: false });
271
+ // Long runs of white space and invisible characters are cut to eight, so padding between two words cannot push them into
272
+ // different chunks; the variants in matchesInstruction read a shortened run the same way as the full one.
273
+ const PADDING_RUN = new RegExp(`[\\s${HIDDEN_CHARS.source.slice(1, -1)}]{9,}`, "g");
274
+ /** The first instruction pattern found in the text, reading it chunk by chunk within the context's budget and deadline. */
275
+ function scanText(text, ctx) {
276
+ const rest = text.length > SCAN_CHUNK ? text.replace(PADDING_RUN, (run) => run.slice(0, 8)) : text;
277
+ for (let start = 0; start < rest.length; start += SCAN_CHUNK - SCAN_OVERLAP) {
278
+ const piece = rest.slice(start, start + SCAN_CHUNK);
279
+ if (piece.length > ctx.budget || performance.now() > ctx.deadline) {
280
+ ctx.unscanned += rest.length - start;
281
+ return undefined;
282
+ }
283
+ ctx.budget -= piece.length;
284
+ const hit = matchesInstruction(piece);
285
+ if (hit)
286
+ return hit;
287
+ if (start + SCAN_CHUNK >= rest.length)
288
+ break;
289
+ }
290
+ return undefined;
291
+ }
292
+ // A key that is a field name is short and has no spaces; a longer one (or one with spaces) can carry text, so it is read as text.
293
+ const isTextKey = (key) => key.length > 24 || /\s/.test(key);
294
+ const idOf = (node) => ID_KEYS.map((key) => node[key]).find((candidate) => typeof candidate === "string");
295
+ const isMachineField = (key, value) => key !== undefined && Object.hasOwn(MACHINE_VALUE, key) && value.length <= MACHINE_VALUE_MAX && MACHINE_VALUE[key](value);
296
+ const isNote = (value) => typeof value === "object" && value !== null && RISK_NOTES.has(value);
297
+ function flag(target, assessment, taint, ctx, ownerId) {
298
+ taint.mark(ownerId ?? UNIDENTIFIED, assessment);
299
+ if (!target) {
300
+ ctx.orphan ??= assessment;
301
+ return;
302
+ }
303
+ if (isNote(target.risk))
304
+ return;
305
+ RISK_NOTES.add(assessment);
306
+ target.risk = assessment;
307
+ }
308
+ const instructionNote = (reason) => ({ level: "high", reasons: [`The message ${reason}.`] });
309
+ /** Runs a scan and remembers the first node it ran short on, so the final note lands on a node of the result. */
310
+ function check(text, ctx, holder, ownerId) {
311
+ const before = ctx.unscanned;
312
+ const hit = scanText(text, ctx);
313
+ if (!hit && ctx.unscanned > before)
314
+ ctx.firstShort ??= { holder, ownerId };
315
+ return hit;
316
+ }
317
+ function visitChild(value, depth, holder, ctx, taint, ownerId) {
318
+ if (depth > MAX_SCAN_DEPTH)
319
+ return { text: "", changed: scanDeep(value, Array.isArray(value) ? holder : value, ctx, taint, ownerId), flagged: false };
320
+ return Array.isArray(value) ? visitArray(value, depth, holder, ctx, taint, ownerId) : visitNode(value, depth, ctx, taint, ownerId);
321
+ }
322
+ function visitNode(node, depth, ctx, taint, outerId) {
323
+ const id = idOf(node) ?? outerId;
324
+ const fields = [];
325
+ const entries = Object.entries(node);
326
+ for (const [key, value] of entries) {
327
+ if (isTextKey(key))
328
+ fields.push(key);
329
+ if (typeof value === "string" && !isMachineField(key, value))
330
+ fields.push(value);
331
+ }
332
+ const text = fields.join(" ");
118
333
  let changed = false;
119
- if (Array.isArray(value)) {
120
- for (const item of value)
121
- if (annotateHighRisk(item, taint, depth + 1))
122
- changed = true;
123
- return changed;
124
- }
125
- const node = value;
126
- if (node.risk === undefined) {
127
- const text = [node.text, node.preview, node.snippet, node.subject].filter((part) => typeof part === "string").join("\n");
128
- const id = ID_KEYS.map((key) => node[key]).find((candidate) => typeof candidate === "string");
129
- if (id && text) {
130
- const assessment = assessMail({ subject: typeof node.subject === "string" ? node.subject : undefined, text });
131
- if (assessment.level === "high") {
132
- node.risk = assessment;
133
- taint.mark(id, assessment);
134
- changed = true;
334
+ let flagged = false;
335
+ if (text) {
336
+ const hit = check(text, ctx, node, id);
337
+ if (hit) {
338
+ flag(node, instructionNote(hit), taint, ctx, id);
339
+ changed = flagged = true;
340
+ }
341
+ }
342
+ for (const [key, child] of entries) {
343
+ if (child === null || typeof child !== "object" || (key === "risk" && isNote(child)))
344
+ continue;
345
+ if (visitChild(child, depth + 1, node, ctx, taint, id).changed)
346
+ changed = true;
347
+ }
348
+ return { text, changed, flagged };
349
+ }
350
+ function visitArray(items, depth, holder, ctx, taint, ownerId) {
351
+ let changed = false;
352
+ const strings = [];
353
+ let prev;
354
+ for (const item of items) {
355
+ if (typeof item === "string") {
356
+ strings.push(item);
357
+ continue;
358
+ }
359
+ if (item === null || typeof item !== "object")
360
+ continue;
361
+ const visited = visitChild(item, depth + 1, holder, ctx, taint, ownerId);
362
+ if (visited.changed)
363
+ changed = true;
364
+ if (Array.isArray(item) || !visited.text)
365
+ continue;
366
+ // Two neighbours that each read harmlessly can carry one instruction between them.
367
+ if (prev && !prev.flagged && !visited.flagged) {
368
+ const node = item;
369
+ const id = idOf(node) ?? ownerId;
370
+ const hit = check(`${prev.text.slice(-SEAM)} ${visited.text.slice(0, SEAM)}`, ctx, node, id);
371
+ if (hit) {
372
+ flag(node, instructionNote(hit), taint, ctx, id);
373
+ changed = visited.flagged = true;
135
374
  }
136
375
  }
376
+ prev = visited;
137
377
  }
138
- for (const [key, child] of Object.entries(node))
139
- if (key !== "risk" && annotateHighRisk(child, taint, depth + 1))
378
+ if (strings.length > 0) {
379
+ const hit = check(strings.join(" "), ctx, holder, ownerId);
380
+ if (hit) {
381
+ flag(holder, instructionNote(hit), taint, ctx, ownerId);
140
382
  changed = true;
141
- return changed;
383
+ }
384
+ }
385
+ return { text: "", changed, flagged: false };
386
+ }
387
+ // Below the depth the recursive walk goes to, the same content is read by an explicit-stack walk (no recursion to exhaust):
388
+ // strings in document order, joined by a space, so deep nesting hides no text and a long chain cannot overflow the stack.
389
+ function scanDeep(root, target, ctx, taint, ownerId) {
390
+ const stack = [[root, undefined]];
391
+ let parts = [];
392
+ let length = 0;
393
+ let hit;
394
+ const flush = () => {
395
+ const joined = parts.join(" ");
396
+ hit = check(joined, ctx, target, ownerId);
397
+ const carry = joined.slice(-SEAM);
398
+ parts = carry ? [carry] : [];
399
+ length = carry.length;
400
+ };
401
+ while (stack.length > 0 && !hit) {
402
+ const [value, key] = stack.pop();
403
+ if (++ctx.flatNodes > MAX_FLAT_NODES) {
404
+ ctx.cut = true;
405
+ ctx.firstShort ??= { holder: target, ownerId };
406
+ break;
407
+ }
408
+ if (typeof value === "string") {
409
+ if (isMachineField(key, value))
410
+ continue;
411
+ parts.push(value);
412
+ length += value.length + 1;
413
+ if (length >= SCAN_CHUNK)
414
+ flush();
415
+ }
416
+ else if (Array.isArray(value)) {
417
+ for (let i = value.length - 1; i >= 0; i--)
418
+ stack.push([value[i], key]);
419
+ }
420
+ else if (value !== null && typeof value === "object") {
421
+ const entries = Object.entries(value);
422
+ for (let i = entries.length - 1; i >= 0; i--) {
423
+ if (entries[i][0] === "risk" && isNote(entries[i][1]))
424
+ continue;
425
+ stack.push([entries[i][1], entries[i][0]]);
426
+ if (isTextKey(entries[i][0]))
427
+ stack.push([entries[i][0], undefined]);
428
+ }
429
+ }
430
+ }
431
+ if (!hit && parts.length > 0)
432
+ flush();
433
+ if (!hit)
434
+ return false;
435
+ flag(target, instructionNote(hit), taint, ctx, ownerId);
436
+ return true;
437
+ }
438
+ function unscannedNote(ctx) {
439
+ const reasons = [];
440
+ if (ctx.unscanned > 0)
441
+ reasons.push(`oversized-unscanned: about ${ctx.unscanned} characters of this result were not checked for instructions to an AI (the scan limit was reached).`);
442
+ if (ctx.cut)
443
+ reasons.push(`unscanned-depth: content nested more than ${MAX_SCAN_DEPTH} levels deep was too large to check for instructions to an AI.`);
444
+ return { level: "high", reasons };
445
+ }
446
+ // Walks a tool result and marks every node whose text reads like an instruction to an AI: any string value except the
447
+ // server's own machine fields, a node's fields read together (an instruction can be split across them) and neighbouring
448
+ // nodes read across their seam. The session is tainted by it and the node gets a `risk` note. Only the instruction
449
+ // patterns are used here: a list of previews would be too noisy with the weaker signals.
450
+ function annotateWith(value, taint, ctx) {
451
+ let changed = false;
452
+ if (value !== null && typeof value === "object")
453
+ changed = visitChild(value, 0, undefined, ctx, taint, undefined).changed;
454
+ if (ctx.unscanned > 0 || ctx.cut) {
455
+ flag(ctx.firstShort?.holder, unscannedNote(ctx), taint, ctx, ctx.firstShort?.ownerId);
456
+ changed = true;
457
+ }
458
+ return { changed, orphan: ctx.orphan };
459
+ }
460
+ /** Marks the nodes of a result that read like an instruction to an AI. Returns whether anything was marked. */
461
+ export function annotateHighRisk(value, taint) {
462
+ return annotateWith(value, taint, newContext(performance.now() + SCAN_DEADLINE_MS)).changed;
463
+ }
464
+ /** A text that is not walked as JSON: read as plain text, or (past the scan size) by its two ends, and said so. */
465
+ function scanPlain(text, taint, deadline) {
466
+ if (text.length > MAX_SCAN_CHARS) {
467
+ // Not walked, so look at both ends and say so rather than let it pass.
468
+ const ends = text.length > 2 * SCAN_WINDOW ? `${text.slice(0, SCAN_WINDOW)}\n${text.slice(-SCAN_WINDOW)}` : text;
469
+ const assessment = assessMail({ text: ends });
470
+ const flagged = assessment.level === "high"
471
+ ? assessment
472
+ : { level: "high", reasons: [`oversized-unscanned: ${Math.max(0, text.length - 2 * SCAN_WINDOW)} characters of this result could not be read as JSON and were not checked for instructions to an AI.`] };
473
+ taint.mark("oversized-unscanned", flagged);
474
+ return flagged;
475
+ }
476
+ const ctx = newContext(deadline);
477
+ const hit = scanText(text, ctx);
478
+ const note = hit ? instructionNote(hit) : ctx.unscanned > 0 ? unscannedNote(ctx) : undefined;
479
+ if (note)
480
+ taint.mark(UNIDENTIFIED, note);
481
+ return note;
482
+ }
483
+ const riskPart = (risk) => ({ type: "text", text: JSON.stringify({ risk }) });
484
+ // The text of a resource part (a string, or a base64 blob read as UTF-8); a blob too big to decode here is reported unread.
485
+ function resourceText(part) {
486
+ const { text, blob } = part.resource ?? {};
487
+ if (typeof text === "string")
488
+ return text;
489
+ if (typeof blob !== "string")
490
+ return undefined;
491
+ if (blob.length > MAX_SCAN_CHARS * 2)
492
+ return "\u0000".repeat(MAX_SCAN_CHARS + 1);
493
+ return Buffer.from(blob, "base64").toString("utf8");
494
+ }
495
+ function alreadyRead(structured, readClean) {
496
+ try {
497
+ return readClean.some(({ text, indent }) => JSON.stringify(structured, null, indent) === text);
498
+ }
499
+ catch {
500
+ return false;
501
+ }
142
502
  }
143
503
  /** A tool handler whose results are scanned for mail that tries to steer an AI, wherever that text came from. */
144
504
  export function withRiskScan(taint, handler) {
@@ -146,27 +506,49 @@ export function withRiskScan(taint, handler) {
146
506
  const result = await handler(request);
147
507
  if (result.isError)
148
508
  return result;
509
+ const deadline = performance.now() + SCAN_DEADLINE_MS;
149
510
  let changed = false;
150
- const content = result.content.map((part) => {
511
+ // Text parts read as JSON and found clean: a structuredContent that writes out to the same text was read with them.
512
+ const readClean = [];
513
+ const content = result.content.flatMap((part) => {
151
514
  const item = part;
152
- if (item.type !== "text" || typeof item.text !== "string" || item.text.length > MAX_SCAN_CHARS || !/^[\s]*[{[]/.test(item.text))
153
- return part;
154
- try {
155
- const parsed = JSON.parse(item.text);
156
- if (!annotateHighRisk(parsed, taint))
157
- return part;
515
+ if (item.type === "resource") {
516
+ const text = resourceText(item);
517
+ const note = text === undefined ? undefined : scanPlain(text, taint, deadline);
518
+ if (!note)
519
+ return [part];
158
520
  changed = true;
159
- return { ...item, text: JSON.stringify(parsed, null, item.text.includes("\n ") ? 2 : undefined) };
521
+ return [part, riskPart(note)];
160
522
  }
161
- catch {
162
- return part;
523
+ if (item.type !== "text" || typeof item.text !== "string")
524
+ return [part];
525
+ if (/^[\s]*[{[]/.test(item.text) && item.text.length <= MAX_PARSE_CHARS) {
526
+ try {
527
+ const parsed = JSON.parse(item.text);
528
+ const scanned = annotateWith(parsed, taint, newContext(deadline));
529
+ if (!scanned.changed) {
530
+ readClean.push({ text: item.text, indent: item.text.includes("\n ") ? 2 : undefined });
531
+ return [part];
532
+ }
533
+ const rewritten = { ...item, text: JSON.stringify(parsed, null, item.text.includes("\n ") ? 2 : undefined) };
534
+ changed = true;
535
+ return scanned.orphan ? [rewritten, riskPart(scanned.orphan)] : [rewritten];
536
+ }
537
+ catch {
538
+ // Not JSON, or too deep to write back: read as plain text below (the session is already tainted if it matters).
539
+ }
163
540
  }
541
+ const note = scanPlain(item.text, taint, deadline);
542
+ if (!note)
543
+ return [part];
544
+ changed = true;
545
+ return [part, riskPart(note)];
164
546
  });
547
+ const structured = result.structuredContent;
548
+ if (structured && typeof structured === "object" && (changed || !alreadyRead(structured, readClean)) && annotateWith(structured, taint, newContext(deadline)).changed)
549
+ changed = true;
165
550
  if (!changed)
166
551
  return result;
167
- const structured = result.structuredContent;
168
- if (structured && typeof structured === "object")
169
- annotateHighRisk(structured, taint);
170
552
  return { ...result, content, ...(structured !== undefined ? { structuredContent: structured } : {}) };
171
553
  };
172
554
  }