tryharness 0.7.23 → 0.7.25

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 (61) hide show
  1. package/assets/opencode-plugin.js +48 -11
  2. package/assets/pi-extension.ts +105 -26
  3. package/cli.mjs +1238 -322
  4. package/lib/activity.mjs +197 -40
  5. package/lib/adapters.mjs +337 -54
  6. package/lib/agentnode.mjs +175 -35
  7. package/lib/atlas.mjs +383 -83
  8. package/lib/cards.mjs +174 -40
  9. package/lib/claudemem.mjs +74 -18
  10. package/lib/commands.mjs +13 -5
  11. package/lib/context.mjs +409 -71
  12. package/lib/control-client.mjs +5 -2
  13. package/lib/coordinator.mjs +274 -57
  14. package/lib/daemon.mjs +5248 -1024
  15. package/lib/desktop.mjs +149 -35
  16. package/lib/digest.mjs +54 -13
  17. package/lib/environment.mjs +103 -21
  18. package/lib/files.mjs +246 -13
  19. package/lib/firewall.mjs +169 -35
  20. package/lib/folders.mjs +179 -36
  21. package/lib/frames.mjs +214 -40
  22. package/lib/inference.mjs +463 -126
  23. package/lib/learn.mjs +78 -16
  24. package/lib/live.mjs +286 -61
  25. package/lib/localmodels.mjs +424 -83
  26. package/lib/mcp-stdio.mjs +868 -151
  27. package/lib/media.mjs +138 -28
  28. package/lib/members.json +1 -1
  29. package/lib/members.mjs +35 -9
  30. package/lib/models.mjs +342 -80
  31. package/lib/network.mjs +201 -42
  32. package/lib/notify.mjs +49 -12
  33. package/lib/nudge.mjs +184 -33
  34. package/lib/ocr.mjs +223 -60
  35. package/lib/onboard.mjs +321 -70
  36. package/lib/parsers.mjs +132 -41
  37. package/lib/paths.mjs +33 -10
  38. package/lib/personal.mjs +51 -13
  39. package/lib/phones.mjs +382 -60
  40. package/lib/places.mjs +555 -107
  41. package/lib/provenance.mjs +19 -4
  42. package/lib/receipts.mjs +51 -11
  43. package/lib/redact.mjs +512 -116
  44. package/lib/runner.mjs +345 -55
  45. package/lib/runtime-install.mjs +55 -14
  46. package/lib/screens.mjs +154 -38
  47. package/lib/sealed.mjs +157 -28
  48. package/lib/secrets.mjs +18 -5
  49. package/lib/service.mjs +170 -34
  50. package/lib/snippets.mjs +94 -18
  51. package/lib/speech.mjs +688 -175
  52. package/lib/store.mjs +1654 -307
  53. package/lib/swarm.mjs +596 -117
  54. package/lib/t3.mjs +400 -79
  55. package/lib/trails.mjs +285 -61
  56. package/lib/ui.mjs +95 -23
  57. package/lib/vectors.mjs +5 -2
  58. package/lib/wakes.mjs +284 -68
  59. package/lib/watches.mjs +247 -48
  60. package/lib/worktrees.mjs +186 -38
  61. package/package.json +3 -3
package/lib/redact.mjs CHANGED
@@ -2,7 +2,17 @@ import fs from "node:fs";
2
2
  import pngjs from "pngjs";
3
3
  import { decodeImage, imageKind } from "./ocr.mjs";
4
4
  import { secretReason } from "./secrets.mjs";
5
- import { BASE58ISH, BECH32, EMAIL, EVM, EVM_SHORT, MONEY, PHONE, base58, phoneLike } from "./personal.mjs";
5
+ import {
6
+ BASE58ISH,
7
+ BECH32,
8
+ EMAIL,
9
+ EVM,
10
+ EVM_SHORT,
11
+ MONEY,
12
+ PHONE,
13
+ base58,
14
+ phoneLike,
15
+ } from "./personal.mjs";
6
16
 
7
17
  // What an image loses before it leaves the computer for an agent on Harness's side (daemon.mjs, board.file.get): the
8
18
  // text in it is read here (ocr.mjs) and every key or password (the handoff firewall's own detection, firewall.mjs),
@@ -14,19 +24,48 @@ import { BASE58ISH, BECH32, EMAIL, EVM, EVM_SHORT, MONEY, PHONE, base58, phoneLi
14
24
  // Bump when what is found or how it is drawn changes: covered copies are kept under this version.
15
25
  export const REDACTION_VERSION = 1;
16
26
 
17
- const LABELS = { credential: "key hidden", "private-key": "key hidden", seed: "seed phrase hidden", wallet: "address hidden", money: "amount hidden", email: "email hidden", phone: "phone hidden" };
27
+ const LABELS = {
28
+ credential: "key hidden",
29
+ "private-key": "key hidden",
30
+ seed: "seed phrase hidden",
31
+ wallet: "address hidden",
32
+ money: "amount hidden",
33
+ email: "email hidden",
34
+ phone: "phone hidden",
35
+ };
18
36
  const NAMES = {
19
- credential: ["a key or password", "keys or passwords"], "private-key": ["a private key", "private keys"], seed: ["a seed phrase", "seed phrases"],
20
- wallet: ["a wallet address", "wallet addresses"], money: ["an amount of money", "amounts of money"], email: ["an email address", "email addresses"], phone: ["a phone number", "phone numbers"],
37
+ credential: ["a key or password", "keys or passwords"],
38
+ "private-key": ["a private key", "private keys"],
39
+ seed: ["a seed phrase", "seed phrases"],
40
+ wallet: ["a wallet address", "wallet addresses"],
41
+ money: ["an amount of money", "amounts of money"],
42
+ email: ["an email address", "email addresses"],
43
+ phone: ["a phone number", "phone numbers"],
21
44
  };
22
45
  // When finds of about the same length overlap, the stronger kind names the box (sensitiveSpans).
23
- const RANK = ["seed", "private-key", "wallet", "credential", "email", "phone", "money"];
46
+ const RANK = [
47
+ "seed",
48
+ "private-key",
49
+ "wallet",
50
+ "credential",
51
+ "email",
52
+ "phone",
53
+ "money",
54
+ ];
24
55
 
25
56
  // The firewall takes any 64-hex value for a private key. On a screen most are file digests, and a row says which: a
26
57
  // sha256: prefix, the "<hex> <file>" line shasum prints, or a digest or checksum label, with no key named on the row.
27
58
  // On such a row nothing is taken for a key on the strength of that value (the firewall may name another token on it).
28
- const digestRow = (row) => !/private|secret|\bkey\b|wallet|seed|mnemonic/i.test(row) && [...row.matchAll(/(?<![0-9a-fA-F])(?:0x)?[0-9a-fA-F]{64}(?![0-9a-fA-F])/g)]
29
- .some((m) => /sha-?(?:256|384|512)[:=]\s*$/i.test(row.slice(0, m.index)) || /^\s+\S+\.\w{1,6}\b/.test(row.slice(m.index + m[0].length)) || /\bsha-?(?:256|384|512)\b|\bdigests?\b|\bchecksums?\b/i.test(row));
59
+ const digestRow = (row) =>
60
+ !/private|secret|\bkey\b|wallet|seed|mnemonic/i.test(row) &&
61
+ [
62
+ ...row.matchAll(/(?<![0-9a-fA-F])(?:0x)?[0-9a-fA-F]{64}(?![0-9a-fA-F])/g),
63
+ ].some(
64
+ (m) =>
65
+ /sha-?(?:256|384|512)[:=]\s*$/i.test(row.slice(0, m.index)) ||
66
+ /^\s+\S+\.\w{1,6}\b/.test(row.slice(m.index + m[0].length)) ||
67
+ /\bsha-?(?:256|384|512)\b|\bdigests?\b|\bchecksums?\b/i.test(row)
68
+ );
30
69
 
31
70
  // Seed phrases (BIP-39, bip39-english.txt: the official English list, MIT, sha256 2f5eed53…dbda): a run of at least
32
71
  // SEED_RUN whitespace-separated list words, nearly all different, in lower or upper case (a capital allowed on the
@@ -35,18 +74,40 @@ const digestRow = (row) => !/private|secret|\bkey\b|wallet|seed|mnemonic/i.test(
35
74
  // a real test phrase (October 14), so nine is far from prose and below every phrase length.
36
75
  const SEED_RUN = 9;
37
76
  let wordlist = null;
38
- const bip39 = () => (wordlist ||= new Set(fs.readFileSync(new URL("./bip39-english.txt", import.meta.url), "utf8").split("\n").map((word) => word.trim()).filter(Boolean)));
77
+ const bip39 = () =>
78
+ (wordlist ||= new Set(
79
+ fs
80
+ .readFileSync(new URL("./bip39-english.txt", import.meta.url), "utf8")
81
+ .split("\n")
82
+ .map((word) => word.trim())
83
+ .filter(Boolean)
84
+ ));
39
85
  const nearWord = (word) => {
40
86
  const list = bip39();
41
87
  if (list.has(word)) return true;
42
88
  if (word.length < 4) return false;
43
89
  for (const candidate of list) {
44
- if (candidate[0] !== word[0] || Math.abs(candidate.length - word.length) > 1) continue;
45
- let i = 0, j = 0, edits = 0;
90
+ if (
91
+ candidate[0] !== word[0] ||
92
+ Math.abs(candidate.length - word.length) > 1
93
+ )
94
+ continue;
95
+ let i = 0,
96
+ j = 0,
97
+ edits = 0;
46
98
  while (i < word.length && j < candidate.length && edits <= 1) {
47
- if (word[i] === candidate[j]) { i += 1; j += 1; continue; }
99
+ if (word[i] === candidate[j]) {
100
+ i += 1;
101
+ j += 1;
102
+ continue;
103
+ }
48
104
  edits += 1;
49
- if (word.length > candidate.length) i += 1; else if (word.length < candidate.length) j += 1; else { i += 1; j += 1; }
105
+ if (word.length > candidate.length) i += 1;
106
+ else if (word.length < candidate.length) j += 1;
107
+ else {
108
+ i += 1;
109
+ j += 1;
110
+ }
50
111
  }
51
112
  if (edits + (word.length - i) + (candidate.length - j) <= 1) return true;
52
113
  }
@@ -56,16 +117,34 @@ const nearWord = (word) => {
56
117
  /** Lines on one visual row joined left to right (two spaces apart, so no pattern runs across boxes), with each line's offset. */
57
118
  function rowsOf(lines) {
58
119
  const rows = [];
59
- for (const line of [...lines].sort((a, b) => (a.box[1] + a.box[3]) - (b.box[1] + b.box[3]))) {
120
+ for (const line of [...lines].sort(
121
+ (a, b) => a.box[1] + a.box[3] - (b.box[1] + b.box[3])
122
+ )) {
60
123
  const [, y0, , y1] = line.box;
61
- const row = rows.find((each) => Math.min(each.y1, y1) - Math.max(each.y0, y0) >= 0.5 * Math.min(each.y1 - each.y0, y1 - y0));
62
- if (row) { row.lines.push(line); row.y0 = Math.min(row.y0, y0); row.y1 = Math.max(row.y1, y1); } else rows.push({ y0, y1, lines: [line] });
124
+ const row = rows.find(
125
+ (each) =>
126
+ Math.min(each.y1, y1) - Math.max(each.y0, y0) >=
127
+ 0.5 * Math.min(each.y1 - each.y0, y1 - y0)
128
+ );
129
+ if (row) {
130
+ row.lines.push(line);
131
+ row.y0 = Math.min(row.y0, y0);
132
+ row.y1 = Math.max(row.y1, y1);
133
+ } else rows.push({ y0, y1, lines: [line] });
63
134
  }
64
- return rows.sort((a, b) => a.y0 - b.y0).map((row) => {
65
- let text = "";
66
- const parts = row.lines.sort((a, b) => a.box[0] - b.box[0]).map((line) => { const at = text ? text.length + 2 : 0; text = text ? `${text} ${line.text}` : line.text; return { line, at }; });
67
- return { text, parts };
68
- });
135
+ return rows
136
+ .sort((a, b) => a.y0 - b.y0)
137
+ .map((row) => {
138
+ let text = "";
139
+ const parts = row.lines
140
+ .sort((a, b) => a.box[0] - b.box[0])
141
+ .map((line) => {
142
+ const at = text ? text.length + 2 : 0;
143
+ text = text ? `${text} ${line.text}` : line.text;
144
+ return { line, at };
145
+ });
146
+ return { text, parts };
147
+ });
69
148
  }
70
149
 
71
150
  // A value wrapped inside its box (a key in a narrow card): a line ending in a long unbroken token, last on its row and
@@ -74,17 +153,42 @@ function rowsOf(lines) {
74
153
  // line (a dotted name is a file or host, not a value cut short); only finds that cross the break count on it.
75
154
  function wrapsOf(lines, rows) {
76
155
  const out = [];
77
- const lastOnRow = new Set(rows.map((row) => row.parts.at(-1).line)), firstOnRow = new Set(rows.map((row) => row.parts[0].line));
156
+ const lastOnRow = new Set(rows.map((row) => row.parts.at(-1).line)),
157
+ firstOnRow = new Set(rows.map((row) => row.parts[0].line));
78
158
  for (const a of lines) {
79
159
  const tail = a.text.match(/\S{16,}$/)?.[0];
80
160
  if (!tail || /\./.test(tail.slice(-16)) || !lastOnRow.has(a)) continue;
81
- const h = a.box[3] - a.box[1], char = (a.box[2] - a.box[0]) / a.text.length;
82
- const column = lines.filter((each) => Math.abs(each.box[0] - a.box[0]) <= 1.5 * char && Math.abs(each.box[1] - a.box[1]) <= 10 * h);
161
+ const h = a.box[3] - a.box[1],
162
+ char = (a.box[2] - a.box[0]) / a.text.length;
163
+ const column = lines.filter(
164
+ (each) =>
165
+ Math.abs(each.box[0] - a.box[0]) <= 1.5 * char &&
166
+ Math.abs(each.box[1] - a.box[1]) <= 10 * h
167
+ );
83
168
  if (column.some((each) => each.box[2] > a.box[2] + 1.5 * char)) continue;
84
- const goesOn = /^(?:0x)?[0-9a-fA-F]+$/.test(tail) ? /^[0-9a-fA-F]+$/ : /^[a-z0-9]+$/.test(tail.slice(-16)) ? /^[a-z0-9]+$/ : /^(?=\D*\d)[A-Za-z0-9+/=_-]+$/;
85
- const b = column.find((each) => each !== a && firstOnRow.has(each) && each.box[1] - a.box[3] >= -0.3 * h && each.box[1] - a.box[3] <= 0.8 * h
86
- && Math.abs(each.box[3] - each.box[1] - h) <= 0.35 * h && goesOn.test(each.text.split(/\s/)[0]));
87
- if (b) out.push({ text: a.text + b.text, parts: [{ line: a, at: 0 }, { line: b, at: a.text.length }], cut: a.text.length });
169
+ const goesOn = /^(?:0x)?[0-9a-fA-F]+$/.test(tail)
170
+ ? /^[0-9a-fA-F]+$/
171
+ : /^[a-z0-9]+$/.test(tail.slice(-16))
172
+ ? /^[a-z0-9]+$/
173
+ : /^(?=\D*\d)[A-Za-z0-9+/=_-]+$/;
174
+ const b = column.find(
175
+ (each) =>
176
+ each !== a &&
177
+ firstOnRow.has(each) &&
178
+ each.box[1] - a.box[3] >= -0.3 * h &&
179
+ each.box[1] - a.box[3] <= 0.8 * h &&
180
+ Math.abs(each.box[3] - each.box[1] - h) <= 0.35 * h &&
181
+ goesOn.test(each.text.split(/\s/)[0])
182
+ );
183
+ if (b)
184
+ out.push({
185
+ text: a.text + b.text,
186
+ parts: [
187
+ { line: a, at: 0 },
188
+ { line: b, at: a.text.length },
189
+ ],
190
+ cut: a.text.length,
191
+ });
88
192
  }
89
193
  return out;
90
194
  }
@@ -92,65 +196,147 @@ function wrapsOf(lines, rows) {
92
196
  // A word that names a password or key (not "monkey", "keyboard", "passed" or "author"); a lowercase name of a word,
93
197
  // digits and dashes (a pod, image or package: keeper-api-7d9f8c6b5d-x2k9p, ami-0c55b159cbfafe1f0), which the credential
94
198
  // model alone sometimes takes for a secret; a value that mixes at least three kinds of character, as passwords do.
95
- const NAMES_A_SECRET = /passw|passphrase|\bpass\b|secret|(?:\b|_|api)keys?\b|key_|token|credential|\bauth(?:orization|[_-]?token)?\b|pwd|\bpin\b/i;
96
- const nameLike = (text) => /^[a-z0-9]+(?:[-._][a-z0-9]+)+$/.test(text) && text.split(/[-._]/).some((part) => /^[a-z]{3,}$/.test(part));
97
- const secretish = (text) => Boolean(secretReason(text)) || (text.length >= 8 && [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(text)).length >= 3);
199
+ const NAMES_A_SECRET =
200
+ /passw|passphrase|\bpass\b|secret|(?:\b|_|api)keys?\b|key_|token|credential|\bauth(?:orization|[_-]?token)?\b|pwd|\bpin\b/i;
201
+ const nameLike = (text) =>
202
+ /^[a-z0-9]+(?:[-._][a-z0-9]+)+$/.test(text) &&
203
+ text.split(/[-._]/).some((part) => /^[a-z]{3,}$/.test(part));
204
+ const secretish = (text) =>
205
+ Boolean(secretReason(text)) ||
206
+ (text.length >= 8 &&
207
+ [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(text))
208
+ .length >= 3);
98
209
  // Tokens with a known key shape on their own (secrets.mjs), wherever the firewall's pick on the line fell; a NAME=
99
210
  // before one is not part of it (OCR may read its first letter as a digit).
100
- const shaped = (text) => [...text.matchAll(/\S+/g)].flatMap((m) => {
101
- const token = m[0].replace(/^\w[\w.-]*=/, "").replace(/^["'`(<[{]+|["'`)>\]},;.]+$/g, "");
102
- const start = m.index + m[0].lastIndexOf(token);
103
- return token.length >= 12 && secretReason(token) ? [{ start, end: start + token.length, reason: secretReason(token) }] : [];
104
- });
211
+ const shaped = (text) =>
212
+ [...text.matchAll(/\S+/g)].flatMap((m) => {
213
+ const token = m[0]
214
+ .replace(/^\w[\w.-]*=/, "")
215
+ .replace(/^["'`(<[{]+|["'`)>\]},;.]+$/g, "");
216
+ const start = m.index + m[0].lastIndexOf(token);
217
+ return token.length >= 12 && secretReason(token)
218
+ ? [{ start, end: start + token.length, reason: secretReason(token) }]
219
+ : [];
220
+ });
105
221
 
106
222
  // A value read with the label that names it: alone on the line under a short label ("Secret access key", then the
107
223
  // key), or in a later cell of a table row whose earlier cell names it ("DEPLOY_TOKEN", then the token).
108
224
  function labelsOf(rows) {
109
225
  const out = [];
110
- const pair = (name, value) => out.push({ text: `${name}: ${value.text}`, parts: [{ line: value, at: name.length + 2 }], cut: name.length + 2 });
226
+ const pair = (name, value) =>
227
+ out.push({
228
+ text: `${name}: ${value.text}`,
229
+ parts: [{ line: value, at: name.length + 2 }],
230
+ cut: name.length + 2,
231
+ });
111
232
  rows.forEach((row, i) => {
112
233
  const next = rows[i + 1];
113
234
  if (next && row.parts.length === 1 && next.parts.length === 1) {
114
- const label = row.parts[0].line, value = next.parts[0].line, h = label.box[3] - label.box[1];
115
- if (label.text.length <= 40 && NAMES_A_SECRET.test(label.text) && Math.abs(value.box[0] - label.box[0]) <= h && value.box[1] - label.box[3] <= 1.2 * h && /^\S{8,}$/.test(value.text)) pair(label.text, value);
235
+ const label = row.parts[0].line,
236
+ value = next.parts[0].line,
237
+ h = label.box[3] - label.box[1];
238
+ if (
239
+ label.text.length <= 40 &&
240
+ NAMES_A_SECRET.test(label.text) &&
241
+ Math.abs(value.box[0] - label.box[0]) <= h &&
242
+ value.box[1] - label.box[3] <= 1.2 * h &&
243
+ /^\S{8,}$/.test(value.text)
244
+ )
245
+ pair(label.text, value);
116
246
  }
117
247
  row.parts.forEach(({ line }, j) => {
118
- const name = line.text.split(/\s+/).find((word) => word.length <= 40 && NAMES_A_SECRET.test(word));
119
- if (name) for (const { line: value } of row.parts.slice(j + 1)) if (/^\S{8,}$/.test(value.text)) pair(name, value);
248
+ const name = line.text
249
+ .split(/\s+/)
250
+ .find((word) => word.length <= 40 && NAMES_A_SECRET.test(word));
251
+ if (name)
252
+ for (const { line: value } of row.parts.slice(j + 1))
253
+ if (/^\S{8,}$/.test(value.text)) pair(name, value);
120
254
  });
121
255
  });
122
256
  return out;
123
257
  }
124
258
 
125
- const matches = (re, text, keep = () => true) => [...text.matchAll(re)].filter((m) => keep(m[0], text.slice(0, m.index))).map((m) => ({ start: m.index, end: m.index + m[0].length }));
259
+ const matches = (re, text, keep = () => true) =>
260
+ [...text.matchAll(re)]
261
+ .filter((m) => keep(m[0], text.slice(0, m.index)))
262
+ .map((m) => ({ start: m.index, end: m.index + m[0].length }));
126
263
 
127
264
  // OCR reads some small lower-case letters as their capitals ("pasSword"), and the credential model leans on such words
128
265
  // (0.53 for that line, 0.99 spelled right), so it reads rows with those capitals lowered inside otherwise lower-case
129
266
  // words. The length is the same, so what it finds maps back as it is.
130
- const uncased = (text) => text.replace(/\b[a-z][a-zA-Z]{2,}\b/g, (word) => (/^[a-zCOSUVWXZ]+$/.test(word) ? word.toLowerCase() : word));
267
+ const uncased = (text) =>
268
+ text.replace(/\b[a-z][a-zA-Z]{2,}\b/g, (word) =>
269
+ /^[a-zCOSUVWXZ]+$/.test(word) ? word.toLowerCase() : word
270
+ );
131
271
 
132
272
  // Keys and passwords: the firewall's patterns and credential model per row. A row can hold more than one, and the
133
273
  // firewall names one per line, so a row is asked again with what was found blanked, up to three times; a later find
134
274
  // counts only if it has a known key shape or mixes upper and lower case letters and digits (not the host or path left
135
275
  // beside a password).
136
- const keyLike = (text) => Boolean(secretReason(text)) || (text.length >= 16 && /[a-z]/.test(text) && /[A-Z]/.test(text) && /\d/.test(text));
276
+ const keyLike = (text) =>
277
+ Boolean(secretReason(text)) ||
278
+ (text.length >= 16 &&
279
+ /[a-z]/.test(text) &&
280
+ /[A-Z]/.test(text) &&
281
+ /\d/.test(text));
137
282
  async function credentials(rows, firewall, lane) {
138
283
  const found = [];
139
284
  let asking = rows.map((row, index) => ({ index, text: row.text }));
140
285
  for (let round = 0; round < 3 && asking.length; round += 1) {
141
- const spans = await firewall.secrets(asking.map((each) => uncased(each.text)).join("\n"), { lane });
286
+ const spans = await firewall.secrets(
287
+ asking.map((each) => uncased(each.text)).join("\n"),
288
+ { lane }
289
+ );
142
290
  const next = new Map();
143
291
  let offset = 0;
144
- const starts = asking.map((each) => { const at = offset; offset += each.text.length + 1; return at; });
292
+ const starts = asking.map((each) => {
293
+ const at = offset;
294
+ offset += each.text.length + 1;
295
+ return at;
296
+ });
145
297
  for (const span of spans) {
146
298
  const which = starts.findLastIndex((at) => at <= span.start);
147
- const row = asking[which], start = span.start - starts[which], end = Math.min(span.end - starts[which], row.text.length);
148
- if (end <= start || found.some((each) => each.row === row.index && each.start < end && start < each.end)) continue;
149
- const text = rows[row.index].text, value = row.text.slice(start, end);
150
- if ((/32-byte hex key/.test(span.reason) && digestRow(text)) || (round > 0 && !keyLike(value))) continue;
151
- if (span.reason === "a credential" && nameLike(value) && !NAMES_A_SECRET.test(text) && !/:\/\/[^\s:@/]+:$/.test(text.slice(0, start))) continue;
152
- next.set(row.index, (next.get(row.index) ?? row.text).slice(0, start) + " ".repeat(end - start) + (next.get(row.index) ?? row.text).slice(end));
153
- found.push({ row: row.index, start, end, kind: /private key|32-byte hex key/.test(span.reason) ? "private-key" : /seed phrase/.test(span.reason) ? "seed" : "credential" });
299
+ const row = asking[which],
300
+ start = span.start - starts[which],
301
+ end = Math.min(span.end - starts[which], row.text.length);
302
+ if (
303
+ end <= start ||
304
+ found.some(
305
+ (each) =>
306
+ each.row === row.index && each.start < end && start < each.end
307
+ )
308
+ )
309
+ continue;
310
+ const text = rows[row.index].text,
311
+ value = row.text.slice(start, end);
312
+ if (
313
+ (/32-byte hex key/.test(span.reason) && digestRow(text)) ||
314
+ (round > 0 && !keyLike(value))
315
+ )
316
+ continue;
317
+ if (
318
+ span.reason === "a credential" &&
319
+ nameLike(value) &&
320
+ !NAMES_A_SECRET.test(text) &&
321
+ !/:\/\/[^\s:@/]+:$/.test(text.slice(0, start))
322
+ )
323
+ continue;
324
+ next.set(
325
+ row.index,
326
+ (next.get(row.index) ?? row.text).slice(0, start) +
327
+ " ".repeat(end - start) +
328
+ (next.get(row.index) ?? row.text).slice(end)
329
+ );
330
+ found.push({
331
+ row: row.index,
332
+ start,
333
+ end,
334
+ kind: /private key|32-byte hex key/.test(span.reason)
335
+ ? "private-key"
336
+ : /seed phrase/.test(span.reason)
337
+ ? "seed"
338
+ : "credential",
339
+ });
154
340
  }
155
341
  asking = [...next].map(([index, text]) => ({ index, text }));
156
342
  }
@@ -161,85 +347,185 @@ async function credentials(rows, firewall, lane) {
161
347
  async function rowSpans(rows, firewall, lane) {
162
348
  const found = [];
163
349
  rows.forEach((row, i) => {
164
- const add = (spans, kind) => { for (const span of spans) found.push({ row: i, ...span, kind }); };
165
- add([...matches(EVM, row.text), ...matches(EVM_SHORT, row.text), ...matches(BECH32, row.text)], "wallet");
350
+ const add = (spans, kind) => {
351
+ for (const span of spans) found.push({ row: i, ...span, kind });
352
+ };
353
+ add(
354
+ [
355
+ ...matches(EVM, row.text),
356
+ ...matches(EVM_SHORT, row.text),
357
+ ...matches(BECH32, row.text),
358
+ ],
359
+ "wallet"
360
+ );
166
361
  // Base58 of 50 characters or more is a private key (WIF, Solana's 64-byte keys), shorter an address.
167
- for (const span of matches(BASE58ISH, row.text, (token, before) => base58(token) && !/sha\d+[-:][A-Za-z0-9+/]*$/i.test(before))) add([span], span.end - span.start >= 50 ? "private-key" : "wallet");
362
+ for (const span of matches(
363
+ BASE58ISH,
364
+ row.text,
365
+ (token, before) =>
366
+ base58(token) && !/sha\d+[-:][A-Za-z0-9+/]*$/i.test(before)
367
+ ))
368
+ add([span], span.end - span.start >= 50 ? "private-key" : "wallet");
168
369
  // Not git@host, and not the user:password@host part of a URL.
169
- add(matches(EMAIL, row.text, (text, before) => !/^git@/i.test(text) && !/:\/\/\S*$/.test(before)), "email");
370
+ add(
371
+ matches(
372
+ EMAIL,
373
+ row.text,
374
+ (text, before) => !/^git@/i.test(text) && !/:\/\/\S*$/.test(before)
375
+ ),
376
+ "email"
377
+ );
170
378
  add(matches(PHONE, row.text, phoneLike), "phone");
171
- add(matches(MONEY, row.text, (text) => !/^[-+−]?\s?(?:US)?[$€£¥₹₩₿Ξ]\s?\d$/.test(text)), "money");
379
+ add(
380
+ matches(
381
+ MONEY,
382
+ row.text,
383
+ (text) => !/^[-+−]?\s?(?:US)?[$€£¥₹₩₿Ξ]\s?\d$/.test(text)
384
+ ),
385
+ "money"
386
+ );
172
387
  for (const span of shaped(row.text)) {
173
388
  const key = /private key|32-byte hex key/.test(span.reason);
174
- if (!(key && digestRow(row.text))) add([span], key ? "private-key" : "credential");
389
+ if (!(key && digestRow(row.text)))
390
+ add([span], key ? "private-key" : "credential");
175
391
  }
176
392
  });
177
- return [...found, ...await credentials(rows, firewall, lane)];
393
+ return [...found, ...(await credentials(rows, firewall, lane))];
178
394
  }
179
395
 
180
396
  // Seed phrase runs over every line in reading order; numbering between words is skipped, anything else ends a run.
181
397
  function seedRuns(rows) {
182
398
  const tokens = [];
183
- for (const row of rows) for (const { line } of row.parts) {
184
- for (const m of line.text.matchAll(/\S+/g)) {
185
- if (/^\d{0,2}[.):]?$/.test(m[0])) continue;
186
- const word = m[0].match(/^(\d{0,2}[.):]?)([A-Za-z]{3,8})$/);
187
- tokens.push(word && nearWord(word[2].toLowerCase()) ? { line, start: m.index + word[1].length, end: m.index + m[0].length, word: word[2] } : null);
399
+ for (const row of rows)
400
+ for (const { line } of row.parts) {
401
+ for (const m of line.text.matchAll(/\S+/g)) {
402
+ if (/^\d{0,2}[.):]?$/.test(m[0])) continue;
403
+ const word = m[0].match(/^(\d{0,2}[.):]?)([A-Za-z]{3,8})$/);
404
+ tokens.push(
405
+ word && nearWord(word[2].toLowerCase())
406
+ ? {
407
+ line,
408
+ start: m.index + word[1].length,
409
+ end: m.index + m[0].length,
410
+ word: word[2],
411
+ }
412
+ : null
413
+ );
414
+ }
188
415
  }
189
- }
190
416
  const runs = [];
191
417
  let run = [];
192
418
  const close = () => {
193
419
  while (run.length > 1 && /^[A-Z][a-z]/.test(run.at(-1).word)) run.pop();
194
420
  const distinct = new Set(run.map((each) => each.word.toLowerCase())).size;
195
- if (Math.min(run.length, distinct + 1) >= SEED_RUN && !run.slice(1).some((each) => /^[A-Z][a-z]/.test(each.word))) runs.push(run);
421
+ if (
422
+ Math.min(run.length, distinct + 1) >= SEED_RUN &&
423
+ !run.slice(1).some((each) => /^[A-Z][a-z]/.test(each.word))
424
+ )
425
+ runs.push(run);
196
426
  run = [];
197
427
  };
198
- for (const token of tokens) { if (token) run.push(token); else close(); }
428
+ for (const token of tokens) {
429
+ if (token) run.push(token);
430
+ else close();
431
+ }
199
432
  close();
200
433
  return runs;
201
434
  }
202
435
 
203
436
  /** The sensitive spans in what OCR read: [{ line, start, end, kind, item }], one item per thing found. */
204
437
  export async function sensitiveSpans(lines, { firewall, lane = "handoff" }) {
205
- const rows = rowsOf(lines), wraps = wrapsOf(lines, rows);
438
+ const rows = rowsOf(lines),
439
+ wraps = wrapsOf(lines, rows);
206
440
  const found = [];
207
441
  let item = 0;
208
442
  // A span of a row becomes a span of each line it crosses.
209
443
  const place = (row, start, end, kind) => {
210
444
  const id = item++;
211
445
  for (const { line, at } of row.parts) {
212
- const from = Math.max(start, at) - at, to = Math.min(end, at + line.text.length) - at;
446
+ const from = Math.max(start, at) - at,
447
+ to = Math.min(end, at + line.text.length) - at;
213
448
  if (to > from) found.push({ line, start: from, end: to, kind, item: id });
214
449
  }
215
450
  };
216
- for (const span of await rowSpans(rows, firewall, lane)) place(rows[span.row], span.start, span.end, span.kind);
217
- if (wraps.length) for (const span of await rowSpans(wraps, firewall, lane)) if (span.start < wraps[span.row].cut && wraps[span.row].cut < span.end) place(wraps[span.row], span.start, span.end, span.kind);
451
+ for (const span of await rowSpans(rows, firewall, lane))
452
+ place(rows[span.row], span.start, span.end, span.kind);
453
+ if (wraps.length)
454
+ for (const span of await rowSpans(wraps, firewall, lane))
455
+ if (span.start < wraps[span.row].cut && wraps[span.row].cut < span.end)
456
+ place(wraps[span.row], span.start, span.end, span.kind);
218
457
  const labels = labelsOf(rows);
219
458
  // Only a value like a password or a known key counts there ("production" beside DEPLOY_TOKEN does not).
220
- if (labels.length) for (const span of await credentials(labels, firewall, lane)) if (span.start >= labels[span.row].cut && secretish(labels[span.row].text.slice(span.start, span.end))) place(labels[span.row], span.start, span.end, span.kind);
221
- for (const run of seedRuns(rows)) { const id = item++; for (const token of run) found.push({ line: token.line, start: token.start, end: token.end, kind: "seed", item: id }); }
459
+ if (labels.length)
460
+ for (const span of await credentials(labels, firewall, lane))
461
+ if (
462
+ span.start >= labels[span.row].cut &&
463
+ secretish(labels[span.row].text.slice(span.start, span.end))
464
+ )
465
+ place(labels[span.row], span.start, span.end, span.kind);
466
+ for (const run of seedRuns(rows)) {
467
+ const id = item++;
468
+ for (const token of run)
469
+ found.push({
470
+ line: token.line,
471
+ start: token.start,
472
+ end: token.end,
473
+ kind: "seed",
474
+ item: id,
475
+ });
476
+ }
222
477
  // Two-factor recovery and backup codes each work as a password, and look like nothing else: after a heading that names
223
478
  // them, every code-shaped token (letters and digits, in dashed groups or 8 to 16 long) is covered, row by row, until a
224
479
  // row holds none (eval image-redaction: the credential model scored them 0.03 to 0.05).
225
480
  for (const [index, row] of rows.entries()) {
226
481
  if (!/\b(?:recovery|backup)\s+codes?\b/i.test(row.text)) continue;
227
- for (let next = index + 1, seen = false; next < Math.min(rows.length, index + 8); next += 1) {
228
- const codes = [...rows[next].text.matchAll(/\b(?=[a-z0-9-]*\d)(?=[a-z0-9-]*[a-z])(?:[a-z0-9]{3,8}(?:-[a-z0-9]{3,8})+|[a-z0-9]{8,16})\b/gi)];
229
- if (!codes.length) { if (seen) break; continue; }
482
+ for (
483
+ let next = index + 1, seen = false;
484
+ next < Math.min(rows.length, index + 8);
485
+ next += 1
486
+ ) {
487
+ const codes = [
488
+ ...rows[next].text.matchAll(
489
+ /\b(?=[a-z0-9-]*\d)(?=[a-z0-9-]*[a-z])(?:[a-z0-9]{3,8}(?:-[a-z0-9]{3,8})+|[a-z0-9]{8,16})\b/gi
490
+ ),
491
+ ];
492
+ if (!codes.length) {
493
+ if (seen) break;
494
+ continue;
495
+ }
230
496
  seen = true;
231
- for (const code of codes) place(rows[next], code.index, code.index + code[0].length, "credential");
497
+ for (const code of codes)
498
+ place(
499
+ rows[next],
500
+ code.index,
501
+ code.index + code[0].length,
502
+ "credential"
503
+ );
232
504
  }
233
505
  }
234
506
  // Overlapping spans on a line merge, named by the longer find (a key holding an address-like stretch is a key), the
235
507
  // stronger kind when about as long.
236
508
  const merged = [];
237
509
  for (const span of found.sort((a, b) => a.start - b.start)) {
238
- const same = merged.find((each) => each.line === span.line && each.start < span.end && span.start < each.end);
239
- if (!same) { merged.push({ ...span, length: span.end - span.start }); continue; }
510
+ const same = merged.find(
511
+ (each) =>
512
+ each.line === span.line &&
513
+ each.start < span.end &&
514
+ span.start < each.end
515
+ );
516
+ if (!same) {
517
+ merged.push({ ...span, length: span.end - span.start });
518
+ continue;
519
+ }
240
520
  const length = span.end - span.start;
241
- if (length > same.length + 2 || (Math.abs(length - same.length) <= 2 && RANK.indexOf(span.kind) < RANK.indexOf(same.kind))) Object.assign(same, { kind: span.kind, item: span.item, length });
242
- same.start = Math.min(same.start, span.start); same.end = Math.max(same.end, span.end);
521
+ if (
522
+ length > same.length + 2 ||
523
+ (Math.abs(length - same.length) <= 2 &&
524
+ RANK.indexOf(span.kind) < RANK.indexOf(same.kind))
525
+ )
526
+ Object.assign(same, { kind: span.kind, item: span.item, length });
527
+ same.start = Math.min(same.start, span.start);
528
+ same.end = Math.max(same.end, span.end);
243
529
  }
244
530
  return merged.map(({ length, ...span }) => span);
245
531
  }
@@ -249,7 +535,11 @@ export async function sensitiveSpans(lines, { firewall, lane = "handoff" }) {
249
535
  * it, where OCR places the space unevenly), with half a character's margin, so no part of a covered character shows.
250
536
  */
251
537
  function boxFor({ line, start, end }, width, height) {
252
- const { box: [x0, y0, x1, y1], chars, text } = line;
538
+ const {
539
+ box: [x0, y0, x1, y1],
540
+ chars,
541
+ text,
542
+ } = line;
253
543
  const n = text.length;
254
544
  const known = chars?.length === n;
255
545
  const edge = (k, side) => {
@@ -260,41 +550,91 @@ function boxFor({ line, start, end }, width, height) {
260
550
  if (side === "end" && /\s/.test(text[k])) return chars[k][1];
261
551
  return (chars[k - 1][1] + chars[k][0]) / 2;
262
552
  };
263
- const pad = Math.max(2, ((x1 - x0) / Math.max(1, n)) * 0.5), lift = (y1 - y0) * 0.08;
264
- return [Math.max(0, Math.floor(edge(start, "start") - pad)), Math.max(0, Math.floor(y0 - lift)), Math.min(width, Math.ceil(edge(end, "end") + pad)), Math.min(height, Math.ceil(y1 + lift))];
553
+ const pad = Math.max(2, ((x1 - x0) / Math.max(1, n)) * 0.5),
554
+ lift = (y1 - y0) * 0.08;
555
+ return [
556
+ Math.max(0, Math.floor(edge(start, "start") - pad)),
557
+ Math.max(0, Math.floor(y0 - lift)),
558
+ Math.min(width, Math.ceil(edge(end, "end") + pad)),
559
+ Math.min(height, Math.ceil(y1 + lift)),
560
+ ];
265
561
  }
266
562
 
267
563
  // A 5x7 pixel font with only the letters the labels use.
268
564
  const GLYPHS = {
269
- a: ".....|.....|.###.|....#|.####|#...#|.####", d: "....#|....#|.####|#...#|#...#|#...#|.####", e: ".....|.....|.###.|#...#|#####|#....|.###.",
270
- h: "#....|#....|#.##.|##..#|#...#|#...#|#...#", i: "..#..|.....|.##..|..#..|..#..|..#..|.###.", k: "#....|#....|#..#.|#.#..|##...|#.#..|#..#.",
271
- l: ".##..|..#..|..#..|..#..|..#..|..#..|.###.", m: ".....|.....|##.#.|#.#.#|#.#.#|#.#.#|#.#.#", n: ".....|.....|#.##.|##..#|#...#|#...#|#...#",
272
- o: ".....|.....|.###.|#...#|#...#|#...#|.###.", p: ".....|.....|####.|#...#|####.|#....|#....", r: ".....|.....|#.##.|##..#|#....|#....|#....",
273
- s: ".....|.....|.####|#....|.###.|....#|####.", t: ".#...|.#...|####.|.#...|.#...|.#..#|..##.", u: ".....|.....|#...#|#...#|#...#|#..##|.##.#",
274
- y: ".....|.....|#...#|#...#|.####|....#|.###.", " ": ".....|.....|.....|.....|.....|.....|.....",
565
+ a: ".....|.....|.###.|....#|.####|#...#|.####",
566
+ d: "....#|....#|.####|#...#|#...#|#...#|.####",
567
+ e: ".....|.....|.###.|#...#|#####|#....|.###.",
568
+ h: "#....|#....|#.##.|##..#|#...#|#...#|#...#",
569
+ i: "..#..|.....|.##..|..#..|..#..|..#..|.###.",
570
+ k: "#....|#....|#..#.|#.#..|##...|#.#..|#..#.",
571
+ l: ".##..|..#..|..#..|..#..|..#..|..#..|.###.",
572
+ m: ".....|.....|##.#.|#.#.#|#.#.#|#.#.#|#.#.#",
573
+ n: ".....|.....|#.##.|##..#|#...#|#...#|#...#",
574
+ o: ".....|.....|.###.|#...#|#...#|#...#|.###.",
575
+ p: ".....|.....|####.|#...#|####.|#....|#....",
576
+ r: ".....|.....|#.##.|##..#|#....|#....|#....",
577
+ s: ".....|.....|.####|#....|.###.|....#|####.",
578
+ t: ".#...|.#...|####.|.#...|.#...|.#..#|..##.",
579
+ u: ".....|.....|#...#|#...#|#...#|#..##|.##.#",
580
+ y: ".....|.....|#...#|#...#|.####|....#|.###.",
581
+ " ": ".....|.....|.....|.....|.....|.....|.....",
275
582
  };
276
583
  // Dark boxes on light screens and light ones on dark screens, so the box itself is always seen.
277
- const DARK = [28, 28, 30], LIGHT = [214, 214, 218];
584
+ const DARK = [28, 28, 30],
585
+ LIGHT = [214, 214, 218];
278
586
 
279
587
  function paint(image, [x0, y0, x1, y1], label) {
280
588
  const { data, width } = image;
281
589
  let sum = 0;
282
- for (let y = y0; y < y1; y += 1) for (let x = x0; x < x1; x += 1) { const p = (y * width + x) * 4; sum += 0.3 * data[p] + 0.59 * data[p + 1] + 0.11 * data[p + 2]; }
283
- const [FILL, INK] = sum / Math.max(1, (x1 - x0) * (y1 - y0)) < 110 ? [LIGHT, DARK] : [DARK, LIGHT];
284
- for (let y = y0; y < y1; y += 1) for (let x = x0; x < x1; x += 1) { const p = (y * width + x) * 4; data[p] = FILL[0]; data[p + 1] = FILL[1]; data[p + 2] = FILL[2]; data[p + 3] = 255; }
590
+ for (let y = y0; y < y1; y += 1)
591
+ for (let x = x0; x < x1; x += 1) {
592
+ const p = (y * width + x) * 4;
593
+ sum += 0.3 * data[p] + 0.59 * data[p + 1] + 0.11 * data[p + 2];
594
+ }
595
+ const [FILL, INK] =
596
+ sum / Math.max(1, (x1 - x0) * (y1 - y0)) < 110
597
+ ? [LIGHT, DARK]
598
+ : [DARK, LIGHT];
599
+ for (let y = y0; y < y1; y += 1)
600
+ for (let x = x0; x < x1; x += 1) {
601
+ const p = (y * width + x) * 4;
602
+ data[p] = FILL[0];
603
+ data[p + 1] = FILL[1];
604
+ data[p + 2] = FILL[2];
605
+ data[p + 3] = 255;
606
+ }
285
607
  // The longest label that fits, at the largest whole scale up to about half the box's height; none if nothing fits.
286
608
  for (const text of [label, "hidden"]) {
287
- for (let scale = Math.max(1, Math.floor(((y1 - y0) * 0.55) / 7)); scale >= 1; scale -= 1) {
288
- const w = (text.length * 6 - 1) * scale, h = 7 * scale;
609
+ for (
610
+ let scale = Math.max(1, Math.floor(((y1 - y0) * 0.55) / 7));
611
+ scale >= 1;
612
+ scale -= 1
613
+ ) {
614
+ const w = (text.length * 6 - 1) * scale,
615
+ h = 7 * scale;
289
616
  if (w > x1 - x0 - 2 * scale || h > y1 - y0) continue;
290
- const left = x0 + Math.floor((x1 - x0 - w) / 2), top = y0 + Math.floor((y1 - y0 - h) / 2);
291
- [...text].forEach((ch, i) => GLYPHS[ch].split("|").forEach((row, gy) => [...row].forEach((on, gx) => {
292
- if (on !== "#") return;
293
- for (let dy = 0; dy < scale; dy += 1) for (let dx = 0; dx < scale; dx += 1) {
294
- const p = ((top + gy * scale + dy) * width + left + (i * 6 + gx) * scale + dx) * 4;
295
- data[p] = INK[0]; data[p + 1] = INK[1]; data[p + 2] = INK[2];
296
- }
297
- })));
617
+ const left = x0 + Math.floor((x1 - x0 - w) / 2),
618
+ top = y0 + Math.floor((y1 - y0 - h) / 2);
619
+ [...text].forEach((ch, i) =>
620
+ GLYPHS[ch].split("|").forEach((row, gy) =>
621
+ [...row].forEach((on, gx) => {
622
+ if (on !== "#") return;
623
+ for (let dy = 0; dy < scale; dy += 1)
624
+ for (let dx = 0; dx < scale; dx += 1) {
625
+ const p =
626
+ ((top + gy * scale + dy) * width +
627
+ left +
628
+ (i * 6 + gx) * scale +
629
+ dx) *
630
+ 4;
631
+ data[p] = INK[0];
632
+ data[p + 1] = INK[1];
633
+ data[p + 2] = INK[2];
634
+ }
635
+ })
636
+ )
637
+ );
298
638
  return;
299
639
  }
300
640
  }
@@ -305,9 +645,20 @@ export function describeMasked(masked) {
305
645
  const items = new Map();
306
646
  for (const entry of masked) items.set(entry.item, entry.kind);
307
647
  const counts = new Map();
308
- for (const kind of items.values()) counts.set(kind, (counts.get(kind) ?? 0) + 1);
309
- const parts = RANK.filter((kind) => counts.has(kind)).map((kind) => (counts.get(kind) === 1 ? NAMES[kind][0] : `${counts.get(kind)} ${NAMES[kind][1]}`));
310
- return { count: items.size, text: parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}` : parts[0] ?? "" };
648
+ for (const kind of items.values())
649
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
650
+ const parts = RANK.filter((kind) => counts.has(kind)).map((kind) =>
651
+ counts.get(kind) === 1
652
+ ? NAMES[kind][0]
653
+ : `${counts.get(kind)} ${NAMES[kind][1]}`
654
+ );
655
+ return {
656
+ count: items.size,
657
+ text:
658
+ parts.length > 1
659
+ ? `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`
660
+ : (parts[0] ?? ""),
661
+ };
311
662
  }
312
663
 
313
664
  /**
@@ -315,20 +666,65 @@ export function describeMasked(masked) {
315
666
  * the original bytes when nothing was found, or { refused } when it cannot be checked (a format other than PNG or JPEG,
316
667
  * an animated PNG, a broken or oversized image).
317
668
  */
318
- export async function redactImage({ bytes, mime }, { ocr, firewall, lane = "handoff" }) {
669
+ export async function redactImage(
670
+ { bytes, mime },
671
+ { ocr, firewall, lane = "handoff" }
672
+ ) {
319
673
  const started = performance.now();
320
674
  const buffer = Buffer.from(bytes);
321
675
  const kind = imageKind(buffer);
322
- if (!kind) return { refused: `only PNG and JPEG images can be checked for private details, and this one is ${String(mime || "of another type").replace(/^image\//, "").toUpperCase()}` };
323
- if (kind === "png" && buffer.includes("acTL")) return { refused: "animated images cannot be checked for private details" };
676
+ if (!kind)
677
+ return {
678
+ refused: `only PNG and JPEG images can be checked for private details, and this one is ${String(
679
+ mime || "of another type"
680
+ )
681
+ .replace(/^image\//, "")
682
+ .toUpperCase()}`,
683
+ };
684
+ if (kind === "png" && buffer.includes("acTL"))
685
+ return { refused: "animated images cannot be checked for private details" };
324
686
  let image;
325
- try { image = decodeImage(buffer); } catch (error) { return { refused: `the image could not be read (${error.message})` }; }
687
+ try {
688
+ image = decodeImage(buffer);
689
+ } catch (error) {
690
+ return { refused: `the image could not be read (${error.message})` };
691
+ }
326
692
  const read = await ocr.read(image, { lane });
327
- const masked = (await sensitiveSpans(read.lines, { firewall, lane })).map((span) => ({ kind: span.kind, box: boxFor(span, image.width, image.height), label: LABELS[span.kind], item: span.item }));
328
- if (!masked.length) return { bytes: buffer, mime, masked, ms: Math.round(performance.now() - started) };
693
+ const masked = (await sensitiveSpans(read.lines, { firewall, lane })).map(
694
+ (span) => ({
695
+ kind: span.kind,
696
+ box: boxFor(span, image.width, image.height),
697
+ label: LABELS[span.kind],
698
+ item: span.item,
699
+ })
700
+ );
701
+ if (!masked.length)
702
+ return {
703
+ bytes: buffer,
704
+ mime,
705
+ masked,
706
+ ms: Math.round(performance.now() - started),
707
+ };
329
708
  for (const entry of masked) paint(image, entry.box, entry.label);
330
709
  let opaque = true;
331
- for (let p = 3; p < image.data.length && opaque; p += 4) opaque = image.data[p] === 255;
332
- const png = pngjs.PNG.sync.write({ width: image.width, height: image.height, data: Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength) }, { colorType: opaque ? 2 : 6, inputHasAlpha: true });
333
- return { bytes: png, mime: "image/png", masked, ms: Math.round(performance.now() - started) };
710
+ for (let p = 3; p < image.data.length && opaque; p += 4)
711
+ opaque = image.data[p] === 255;
712
+ const png = pngjs.PNG.sync.write(
713
+ {
714
+ width: image.width,
715
+ height: image.height,
716
+ data: Buffer.from(
717
+ image.data.buffer,
718
+ image.data.byteOffset,
719
+ image.data.byteLength
720
+ ),
721
+ },
722
+ { colorType: opaque ? 2 : 6, inputHasAlpha: true }
723
+ );
724
+ return {
725
+ bytes: png,
726
+ mime: "image/png",
727
+ masked,
728
+ ms: Math.round(performance.now() - started),
729
+ };
334
730
  }