tryharness 0.7.22 → 0.7.23

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/lib/redact.mjs ADDED
@@ -0,0 +1,334 @@
1
+ import fs from "node:fs";
2
+ import pngjs from "pngjs";
3
+ import { decodeImage, imageKind } from "./ocr.mjs";
4
+ import { secretReason } from "./secrets.mjs";
5
+ import { BASE58ISH, BECH32, EMAIL, EVM, EVM_SHORT, MONEY, PHONE, base58, phoneLike } from "./personal.mjs";
6
+
7
+ // What an image loses before it leaves the computer for an agent on Harness's side (daemon.mjs, board.file.get): the
8
+ // text in it is read here (ocr.mjs) and every key or password (the handoff firewall's own detection, firewall.mjs),
9
+ // wallet address, private key, seed phrase, amount of money, email address and phone number is covered by a solid box
10
+ // labelled with what was there. A box, not a fill from the surroundings: the reading agent sees that something was
11
+ // removed, and nothing of it is left to reconstruct. Only that part of a line is covered, from where its characters were
12
+ // read, with a margin so no character of it stays legible. The person and the agents on this computer keep the original.
13
+
14
+ // Bump when what is found or how it is drawn changes: covered copies are kept under this version.
15
+ export const REDACTION_VERSION = 1;
16
+
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" };
18
+ 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"],
21
+ };
22
+ // 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"];
24
+
25
+ // 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
+ // sha256: prefix, the "<hex> <file>" line shasum prints, or a digest or checksum label, with no key named on the row.
27
+ // 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));
30
+
31
+ // Seed phrases (BIP-39, bip39-english.txt: the official English list, MIT, sha256 2f5eed53…dbda): a run of at least
32
+ // SEED_RUN whitespace-separated list words, nearly all different, in lower or upper case (a capital allowed on the
33
+ // first), with numbering between them however OCR read it ("5.", "5frost", ". tomato"). Words read with one wrong
34
+ // letter still count. Over 2,326 of this repository's text and code files, the only run of eight or more such words was
35
+ // a real test phrase (October 14), so nine is far from prose and below every phrase length.
36
+ const SEED_RUN = 9;
37
+ 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)));
39
+ const nearWord = (word) => {
40
+ const list = bip39();
41
+ if (list.has(word)) return true;
42
+ if (word.length < 4) return false;
43
+ 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;
46
+ while (i < word.length && j < candidate.length && edits <= 1) {
47
+ if (word[i] === candidate[j]) { i += 1; j += 1; continue; }
48
+ edits += 1;
49
+ if (word.length > candidate.length) i += 1; else if (word.length < candidate.length) j += 1; else { i += 1; j += 1; }
50
+ }
51
+ if (edits + (word.length - i) + (candidate.length - j) <= 1) return true;
52
+ }
53
+ return false;
54
+ };
55
+
56
+ /** Lines on one visual row joined left to right (two spaces apart, so no pattern runs across boxes), with each line's offset. */
57
+ function rowsOf(lines) {
58
+ const rows = [];
59
+ for (const line of [...lines].sort((a, b) => (a.box[1] + a.box[3]) - (b.box[1] + b.box[3]))) {
60
+ 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] });
63
+ }
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
+ });
69
+ }
70
+
71
+ // 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
72
+ // the widest line starting at its left edge nearby, with the line right below starting at that edge and going on in the
73
+ // same kind of characters (hex after hex, lower case after bech32, a digit somewhere after anything else). Read as one
74
+ // line (a dotted name is a file or host, not a value cut short); only finds that cross the break count on it.
75
+ function wrapsOf(lines, rows) {
76
+ 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));
78
+ for (const a of lines) {
79
+ const tail = a.text.match(/\S{16,}$/)?.[0];
80
+ 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);
83
+ 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 });
88
+ }
89
+ return out;
90
+ }
91
+
92
+ // A word that names a password or key (not "monkey", "keyboard", "passed" or "author"); a lowercase name of a word,
93
+ // digits and dashes (a pod, image or package: keeper-api-7d9f8c6b5d-x2k9p, ami-0c55b159cbfafe1f0), which the credential
94
+ // 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);
98
+ // Tokens with a known key shape on their own (secrets.mjs), wherever the firewall's pick on the line fell; a NAME=
99
+ // 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
+ });
105
+
106
+ // A value read with the label that names it: alone on the line under a short label ("Secret access key", then the
107
+ // key), or in a later cell of a table row whose earlier cell names it ("DEPLOY_TOKEN", then the token).
108
+ function labelsOf(rows) {
109
+ const out = [];
110
+ const pair = (name, value) => out.push({ text: `${name}: ${value.text}`, parts: [{ line: value, at: name.length + 2 }], cut: name.length + 2 });
111
+ rows.forEach((row, i) => {
112
+ const next = rows[i + 1];
113
+ 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);
116
+ }
117
+ 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);
120
+ });
121
+ });
122
+ return out;
123
+ }
124
+
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 }));
126
+
127
+ // OCR reads some small lower-case letters as their capitals ("pasSword"), and the credential model leans on such words
128
+ // (0.53 for that line, 0.99 spelled right), so it reads rows with those capitals lowered inside otherwise lower-case
129
+ // 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));
131
+
132
+ // Keys and passwords: the firewall's patterns and credential model per row. A row can hold more than one, and the
133
+ // firewall names one per line, so a row is asked again with what was found blanked, up to three times; a later find
134
+ // 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
+ // beside a password).
136
+ const keyLike = (text) => Boolean(secretReason(text)) || (text.length >= 16 && /[a-z]/.test(text) && /[A-Z]/.test(text) && /\d/.test(text));
137
+ async function credentials(rows, firewall, lane) {
138
+ const found = [];
139
+ let asking = rows.map((row, index) => ({ index, text: row.text }));
140
+ 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 });
142
+ const next = new Map();
143
+ let offset = 0;
144
+ const starts = asking.map((each) => { const at = offset; offset += each.text.length + 1; return at; });
145
+ for (const span of spans) {
146
+ 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" });
154
+ }
155
+ asking = [...next].map(([index, text]) => ({ index, text }));
156
+ }
157
+ return found;
158
+ }
159
+
160
+ /** Everything but seed phrases on rows: [{ row, start, end, kind }]. */
161
+ async function rowSpans(rows, firewall, lane) {
162
+ const found = [];
163
+ 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");
166
+ // 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");
168
+ // 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");
170
+ add(matches(PHONE, row.text, phoneLike), "phone");
171
+ add(matches(MONEY, row.text, (text) => !/^[-+−]?\s?(?:US)?[$€£¥₹₩₿Ξ]\s?\d$/.test(text)), "money");
172
+ for (const span of shaped(row.text)) {
173
+ const key = /private key|32-byte hex key/.test(span.reason);
174
+ if (!(key && digestRow(row.text))) add([span], key ? "private-key" : "credential");
175
+ }
176
+ });
177
+ return [...found, ...await credentials(rows, firewall, lane)];
178
+ }
179
+
180
+ // Seed phrase runs over every line in reading order; numbering between words is skipped, anything else ends a run.
181
+ function seedRuns(rows) {
182
+ 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);
188
+ }
189
+ }
190
+ const runs = [];
191
+ let run = [];
192
+ const close = () => {
193
+ while (run.length > 1 && /^[A-Z][a-z]/.test(run.at(-1).word)) run.pop();
194
+ 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);
196
+ run = [];
197
+ };
198
+ for (const token of tokens) { if (token) run.push(token); else close(); }
199
+ close();
200
+ return runs;
201
+ }
202
+
203
+ /** The sensitive spans in what OCR read: [{ line, start, end, kind, item }], one item per thing found. */
204
+ export async function sensitiveSpans(lines, { firewall, lane = "handoff" }) {
205
+ const rows = rowsOf(lines), wraps = wrapsOf(lines, rows);
206
+ const found = [];
207
+ let item = 0;
208
+ // A span of a row becomes a span of each line it crosses.
209
+ const place = (row, start, end, kind) => {
210
+ const id = item++;
211
+ for (const { line, at } of row.parts) {
212
+ const from = Math.max(start, at) - at, to = Math.min(end, at + line.text.length) - at;
213
+ if (to > from) found.push({ line, start: from, end: to, kind, item: id });
214
+ }
215
+ };
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);
218
+ const labels = labelsOf(rows);
219
+ // 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 }); }
222
+ // Two-factor recovery and backup codes each work as a password, and look like nothing else: after a heading that names
223
+ // them, every code-shaped token (letters and digits, in dashed groups or 8 to 16 long) is covered, row by row, until a
224
+ // row holds none (eval image-redaction: the credential model scored them 0.03 to 0.05).
225
+ for (const [index, row] of rows.entries()) {
226
+ 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; }
230
+ seen = true;
231
+ for (const code of codes) place(rows[next], code.index, code.index + code[0].length, "credential");
232
+ }
233
+ }
234
+ // Overlapping spans on a line merge, named by the longer find (a key holding an address-like stretch is a key), the
235
+ // stronger kind when about as long.
236
+ const merged = [];
237
+ 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; }
240
+ 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);
243
+ }
244
+ return merged.map(({ length, ...span }) => span);
245
+ }
246
+
247
+ /**
248
+ * The box covering characters start..end of a line: from halfway between neighbouring characters (all of a space beside
249
+ * it, where OCR places the space unevenly), with half a character's margin, so no part of a covered character shows.
250
+ */
251
+ function boxFor({ line, start, end }, width, height) {
252
+ const { box: [x0, y0, x1, y1], chars, text } = line;
253
+ const n = text.length;
254
+ const known = chars?.length === n;
255
+ const edge = (k, side) => {
256
+ if (k <= 0) return x0;
257
+ if (k >= n) return x1;
258
+ if (!known) return x0 + ((x1 - x0) * k) / n;
259
+ if (side === "start" && /\s/.test(text[k - 1])) return chars[k - 1][0];
260
+ if (side === "end" && /\s/.test(text[k])) return chars[k][1];
261
+ return (chars[k - 1][1] + chars[k][0]) / 2;
262
+ };
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))];
265
+ }
266
+
267
+ // A 5x7 pixel font with only the letters the labels use.
268
+ const GLYPHS = {
269
+ a: ".....|.....|.###.|....#|.####|#...#|.####", d: "....#|....#|.####|#...#|#...#|#...#|.####", e: ".....|.....|.###.|#...#|#####|#....|.###.",
270
+ h: "#....|#....|#.##.|##..#|#...#|#...#|#...#", i: "..#..|.....|.##..|..#..|..#..|..#..|.###.", k: "#....|#....|#..#.|#.#..|##...|#.#..|#..#.",
271
+ l: ".##..|..#..|..#..|..#..|..#..|..#..|.###.", m: ".....|.....|##.#.|#.#.#|#.#.#|#.#.#|#.#.#", n: ".....|.....|#.##.|##..#|#...#|#...#|#...#",
272
+ o: ".....|.....|.###.|#...#|#...#|#...#|.###.", p: ".....|.....|####.|#...#|####.|#....|#....", r: ".....|.....|#.##.|##..#|#....|#....|#....",
273
+ s: ".....|.....|.####|#....|.###.|....#|####.", t: ".#...|.#...|####.|.#...|.#...|.#..#|..##.", u: ".....|.....|#...#|#...#|#...#|#..##|.##.#",
274
+ y: ".....|.....|#...#|#...#|.####|....#|.###.", " ": ".....|.....|.....|.....|.....|.....|.....",
275
+ };
276
+ // 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];
278
+
279
+ function paint(image, [x0, y0, x1, y1], label) {
280
+ const { data, width } = image;
281
+ 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; }
285
+ // The longest label that fits, at the largest whole scale up to about half the box's height; none if nothing fits.
286
+ 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;
289
+ 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
+ })));
298
+ return;
299
+ }
300
+ }
301
+ }
302
+
303
+ /** "a key or password, a wallet address and 2 amounts of money" for what redactImage covered. */
304
+ export function describeMasked(masked) {
305
+ const items = new Map();
306
+ for (const entry of masked) items.set(entry.item, entry.kind);
307
+ 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] ?? "" };
311
+ }
312
+
313
+ /**
314
+ * The image with what must not leave the computer covered: { bytes, mime, masked: [{ kind, box, label, item }], ms },
315
+ * the original bytes when nothing was found, or { refused } when it cannot be checked (a format other than PNG or JPEG,
316
+ * an animated PNG, a broken or oversized image).
317
+ */
318
+ export async function redactImage({ bytes, mime }, { ocr, firewall, lane = "handoff" }) {
319
+ const started = performance.now();
320
+ const buffer = Buffer.from(bytes);
321
+ 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" };
324
+ let image;
325
+ try { image = decodeImage(buffer); } catch (error) { return { refused: `the image could not be read (${error.message})` }; }
326
+ 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) };
329
+ for (const entry of masked) paint(image, entry.box, entry.label);
330
+ 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) };
334
+ }
package/lib/runner.mjs CHANGED
@@ -192,6 +192,8 @@ export function runAgentTask(task, emit, observe = () => {}) {
192
192
  let child;
193
193
  try {
194
194
  child = spawn(invocation.command, invocation.args, { cwd, env: environment.env });
195
+ // An agent that exits before reading its brief breaks the pipe; its exit says how the run went.
196
+ child.stdin.on("error", () => {});
195
197
  child.stdin.end(invocation.stdin);
196
198
  } catch (error) {
197
199
  running.delete(slot);
@@ -12,10 +12,10 @@ function dependencyTarget(root, name) {
12
12
  return path.join(root, "node_modules", ...name.split("/"));
13
13
  }
14
14
 
15
- // What the runtime keeps of a large package. onnxruntime-web ships browser bundles for every
16
- // backend (125 MB); the daemon needs its Node entry and the one wasm binary that entry loads.
15
+ // What the runtime keeps of a large package. onnxruntime-node ships native binaries for every OS (about 200 MB); the
16
+ // daemon needs its JavaScript and this machine's own binary.
17
17
  const KEEP = {
18
- "onnxruntime-web": ["package.json", "types.d.ts", "dist/ort.node.min.mjs", "dist/ort.node.min.js", "dist/ort-wasm-simd-threaded.wasm", "dist/ort-wasm-simd-threaded.mjs"],
18
+ "onnxruntime-node": ["package.json", "dist", "lib", `bin/napi-v6/${process.platform}/${process.arch}`],
19
19
  };
20
20
 
21
21
  function copyPackage(source, target, name) {
@@ -25,7 +25,7 @@ function copyPackage(source, target, name) {
25
25
  const from = path.join(source, file);
26
26
  if (!fs.existsSync(from)) continue;
27
27
  ensureDir(path.dirname(path.join(target, file)));
28
- fs.cpSync(from, path.join(target, file), { force: true });
28
+ fs.cpSync(from, path.join(target, file), { recursive: true, force: true });
29
29
  }
30
30
  }
31
31
 
@@ -76,7 +76,8 @@ export function installRuntime({ sourceRoot, version }) {
76
76
  if (!fs.existsSync(source)) continue;
77
77
  fs.cpSync(source, path.join(staging, entry), { recursive: true, force: true });
78
78
  }
79
- copyDependencyClosure(sourceRoot, staging, ["node-sqlite3-wasm", "ws", "onnxruntime-web", "@huggingface/tokenizers", "@clack/prompts", "@clack/core", "picocolors"]);
79
+ // Everything the package depends on, so a dependency added there cannot be left out here.
80
+ copyDependencyClosure(sourceRoot, staging, Object.keys(readJson(path.join(sourceRoot, "package.json"), {}).dependencies || {}));
80
81
  fs.writeFileSync(path.join(staging, ".ready"), version + "\n", { mode: 0o600 });
81
82
  ensureDir(path.dirname(release));
82
83
  fs.rmSync(release, { recursive: true, force: true });
@@ -0,0 +1,96 @@
1
+ import { decodeImage } from "./ocr.mjs";
2
+
3
+ // Two screenshots of the same screen, before and after an agent's UI change: which parts of the picture changed, and
4
+ // what the text on screen says now that it did not before. The pixels find where; the text read on this computer
5
+ // (ocr.mjs) finds what, down to a lost space ("yet OPEN" becoming "yetOPEN"). Code only, no model judges: the agent
6
+ // that made the change reads the list against what it was asked to do.
7
+
8
+ const CELL = 16;
9
+ // A cell counts as changed when its pixels differ by more than this on average (0 to 255): re-rendering the same page
10
+ // moves anti-aliasing by a little; the ui-checks eval set it against identical renders.
11
+ const CELL_DIFF = 6;
12
+ const MIN_OVERLAP = 0.3;
13
+
14
+ const luminance = (data, i) => 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
15
+ const overlap = (a, b) => {
16
+ const w = Math.max(0, Math.min(a[2], b[2]) - Math.max(a[0], b[0])), h = Math.max(0, Math.min(a[3], b[3]) - Math.max(a[1], b[1]));
17
+ const area = (box) => Math.max(1, (box[2] - box[0]) * (box[3] - box[1]));
18
+ return (w * h) / Math.min(area(a), area(b));
19
+ };
20
+
21
+ /** Changed regions of two same-size images: [{ box: [x0, y0, x1, y1], share }], largest first. */
22
+ export function changedRegions(before, after, { cell = CELL, threshold = CELL_DIFF } = {}) {
23
+ const cols = Math.ceil(before.width / cell), rows = Math.ceil(before.height / cell);
24
+ const changed = new Uint8Array(cols * rows);
25
+ for (let cy = 0; cy < rows; cy += 1) for (let cx = 0; cx < cols; cx += 1) {
26
+ let sum = 0, count = 0;
27
+ for (let y = cy * cell; y < Math.min(before.height, (cy + 1) * cell); y += 1) for (let x = cx * cell; x < Math.min(before.width, (cx + 1) * cell); x += 1) {
28
+ const i = (y * before.width + x) * 4;
29
+ sum += Math.abs(luminance(before.data, i) - luminance(after.data, i)) + Math.abs(before.data[i] - after.data[i]) / 3 + Math.abs(before.data[i + 2] - after.data[i + 2]) / 3;
30
+ count += 1;
31
+ }
32
+ if (sum / count > threshold) changed[cy * cols + cx] = 1;
33
+ }
34
+ // Touching changed cells become one region.
35
+ const regions = [];
36
+ const seen = new Uint8Array(cols * rows);
37
+ for (let start = 0; start < changed.length; start += 1) {
38
+ if (!changed[start] || seen[start]) continue;
39
+ const stack = [start];
40
+ seen[start] = 1;
41
+ let x0 = Infinity, y0 = Infinity, x1 = -1, y1 = -1, cells = 0;
42
+ while (stack.length) {
43
+ const at = stack.pop(), cx = at % cols, cy = (at - cx) / cols;
44
+ cells += 1; x0 = Math.min(x0, cx); y0 = Math.min(y0, cy); x1 = Math.max(x1, cx); y1 = Math.max(y1, cy);
45
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]]) {
46
+ const nx = cx + dx, ny = cy + dy, next = ny * cols + nx;
47
+ if (nx >= 0 && ny >= 0 && nx < cols && ny < rows && changed[next] && !seen[next]) { seen[next] = 1; stack.push(next); }
48
+ }
49
+ }
50
+ regions.push({ box: [x0 * cell, y0 * cell, Math.min(before.width, (x1 + 1) * cell), Math.min(before.height, (y1 + 1) * cell)], share: Math.round((cells / (cols * rows)) * 10_000) / 10_000 });
51
+ }
52
+ return regions.sort((a, b) => b.share - a.share);
53
+ }
54
+
55
+ /** Lines of text read before and after, paired by where they sit: { changed: [{ before, after, box }], moved, removed, added }. */
56
+ export function textChanges(beforeLines, afterLines) {
57
+ const used = new Set();
58
+ const changed = [], removed = [];
59
+ for (const line of beforeLines) {
60
+ const same = afterLines.findIndex((other, i) => !used.has(i) && other.text === line.text && overlap(line.box, other.box) >= MIN_OVERLAP);
61
+ if (same >= 0) { used.add(same); continue; }
62
+ let best = -1, bestOverlap = MIN_OVERLAP;
63
+ afterLines.forEach((other, i) => { const o = overlap(line.box, other.box); if (!used.has(i) && o >= bestOverlap) { best = i; bestOverlap = o; } });
64
+ if (best >= 0) { used.add(best); changed.push({ before: line.text, after: afterLines[best].text, box: afterLines[best].box }); }
65
+ else removed.push({ text: line.text, box: line.box });
66
+ }
67
+ const added = afterLines.filter((_, i) => !used.has(i)).map((line) => ({ text: line.text, box: line.box }));
68
+ // The same words gone from one place and new in another moved.
69
+ const moved = [];
70
+ for (const line of [...removed]) {
71
+ const to = added.findIndex((other) => other.text === line.text);
72
+ if (to < 0) continue;
73
+ moved.push({ text: line.text, from: line.box, to: added[to].box });
74
+ removed.splice(removed.indexOf(line), 1);
75
+ added.splice(to, 1);
76
+ }
77
+ return { changed, moved, removed, added };
78
+ }
79
+
80
+ /** Compares two screenshots (PNG or JPEG bytes): { size, regions, text, summary }. */
81
+ export async function compareScreens(beforeBytes, afterBytes, { ocr, lane = "interactive" }) {
82
+ const before = decodeImage(beforeBytes), after = decodeImage(afterBytes);
83
+ if (!before || !after) throw new Error("both screenshots must be PNG or JPEG");
84
+ const sameSize = before.width === after.width && before.height === after.height;
85
+ const regions = sameSize ? changedRegions(before, after) : [];
86
+ const [readBefore, readAfter] = [await ocr.read(before, { lane }), await ocr.read(after, { lane })];
87
+ const text = textChanges(readBefore.lines, readAfter.lines);
88
+ const parts = [];
89
+ if (!sameSize) parts.push(`the screenshots differ in size (${before.width}x${before.height} and ${after.width}x${after.height}), so only their text was compared`);
90
+ else parts.push(regions.length ? `${regions.length} region${regions.length === 1 ? "" : "s"} of the picture changed` : "no part of the picture changed");
91
+ for (const change of text.changed) parts.push(`"${change.before}" now reads "${change.after}"`);
92
+ for (const line of text.moved) parts.push(`"${line.text}" moved`);
93
+ if (text.removed.length) parts.push(`gone: ${text.removed.map((line) => `"${line.text}"`).join(", ")}`);
94
+ if (text.added.length) parts.push(`new: ${text.added.map((line) => `"${line.text}"`).join(", ")}`);
95
+ return { size: { before: [before.width, before.height], after: [after.width, after.height] }, regions: regions.slice(0, 20), text, summary: `${parts.join("; ")}.`, ms: readBefore.ms + readAfter.ms };
96
+ }
package/lib/sealed.mjs CHANGED
@@ -13,6 +13,9 @@ import { timingSafeEqual, webcrypto } from "node:crypto";
13
13
  // - Every message: AES-256-GCM under HKDF-SHA256(ECDH(device, daemon)), a fresh 96-bit nonce,
14
14
  // and the direction, device and request id bound as associated data. Requests carry a
15
15
  // timestamp; the daemon refuses stale ones and any request id it has already seen.
16
+ // - The other way round, the daemon asks a paired browser (its screen history): the question goes
17
+ // "d2b" and the answer "b2d" under the same key, so neither opens as a message of the device's
18
+ // own calls. The browser applies the same freshness and replay rules (phones.mjs ask).
16
19
  // Not provided: forward secrecy (both keys are static). Revoking a device ends its key.
17
20
 
18
21
  const subtle = webcrypto.subtle;
package/lib/service.mjs CHANGED
@@ -118,12 +118,14 @@ export function installService({ runtimeRoot, nodePath = process.execPath, platf
118
118
  if (platform === "darwin") {
119
119
  const plist = servicePath();
120
120
  ensureDir(path.dirname(plist));
121
+ // MallocLargeCache=0: macOS otherwise keeps the large blocks ONNX Runtime frees (a screenshot's OCR held 1.6 GB after
122
+ // it finished; 255 MB with it off, image-redaction eval).
121
123
  fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
122
124
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
123
125
  <plist version="1.0"><dict>
124
126
  <key>Label</key><string>${LABEL}</string>
125
127
  <key>ProgramArguments</key><array><string>${xml(nodePath)}</string><string>${xml(entry)}</string><string>daemon</string></array>
126
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(envPath)}</string></dict>
128
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(envPath)}</string><key>MallocLargeCache</key><string>0</string></dict>
127
129
  <key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>5</integer>
128
130
  <key>StandardOutPath</key><string>${xml(paths.log)}</string><key>StandardErrorPath</key><string>${xml(paths.log)}</string>
129
131
  </dict></plist>\n`, { mode: 0o600 });
@@ -145,7 +147,7 @@ export function installService({ runtimeRoot, nodePath = process.execPath, platf
145
147
  }
146
148
  // Other Node platforms get a ready daemon for this login only. `doctor` reports that
147
149
  // login persistence is unavailable rather than pretending it was set.
148
- const child = spawn(nodePath, [entry, "daemon"], { detached: true, stdio: "ignore", env: { ...process.env, PATH: envPath } });
150
+ const child = spawn(nodePath, [entry, "daemon"], { detached: true, stdio: "ignore", env: { ...process.env, PATH: envPath, ...(process.platform === "darwin" ? { MallocLargeCache: "0" } : {}) } });
149
151
  child.unref();
150
152
  return { manager: "detached", file: null };
151
153
  }
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { nameOf } from "./members.mjs";
5
+ import { repositoryOf } from "./worktrees.mjs";
6
+
7
+ // Code for an agent that does not run on this computer (Grok Bot, Muse) leaves it only with the person's OK. The agent
8
+ // says what it needs to see (request_snippet); the relay finds it in the thread's folder with its local search, takes
9
+ // the lines around it, masks any secret in them, and holds them. The person shares or keeps them (the buddy, or
10
+ // `tryharness board snippet <id> share|keep`); shared lines are posted in the thread for that agent, kept ones never leave.
11
+
12
+ const BEFORE = 5;
13
+ const LINES = 40;
14
+
15
+ export function createSnippets({ store, search, scrub, wake = () => {}, notify = () => {}, log = () => {} }) {
16
+ const read = (id) => { try { return JSON.parse(store.meta(`snippet:${id}`) || "null"); } catch { return null; } };
17
+ const save = (snippet) => {
18
+ store.meta(`snippet:${snippet.id}`, JSON.stringify(snippet));
19
+ const ids = JSON.parse(store.meta("snippets") || "[]");
20
+ if (!ids.includes(snippet.id)) store.meta("snippets", JSON.stringify([...ids, snippet.id].slice(-200)));
21
+ };
22
+
23
+ /** A cloud agent's request: { ok, snippet } held for the person, or why there is nothing to hold. */
24
+ const request = async ({ threadId, agent, about }) => {
25
+ const thread = store.getTask(threadId);
26
+ const folder = thread?.kind === "goal" ? thread.goal.workspace : null;
27
+ if (!folder) return { ok: false, error: "this thread has no folder on the person's computer to show code from" };
28
+ const words = String(about || "").trim().slice(0, 500);
29
+ if (!words) return { ok: false, error: "say what you need to see" };
30
+ const found = await search({ query: words, folder, limit: 1 });
31
+ const file = found?.ok ? found.files[0] : null;
32
+ if (!file) return { ok: false, error: found?.error || "nothing in this thread's folder matched that" };
33
+ const root = repositoryOf(folder)?.root ?? folder;
34
+ let lines;
35
+ try { lines = fs.readFileSync(path.join(root, file.path), "utf8").split("\n"); } catch (error) { return { ok: false, error: `${file.path} could not be read (${error.code || error.message})` }; }
36
+ const from = Math.max(1, file.line - BEFORE), to = Math.min(lines.length, from + LINES - 1);
37
+ // A secret in the lines stays a placeholder for good: it is never filled back in for an agent elsewhere.
38
+ const { text, masked } = await scrub(lines.slice(from - 1, to).join("\n"));
39
+ const snippet = { id: randomUUID(), thread: threadId, agent, about: words, path: file.path, from, to, text, masked, state: "waiting", at: new Date().toISOString() };
40
+ save(snippet);
41
+ store.roomPost({ goalId: threadId, author: "harness", kind: "note", text: `${nameOf(agent)} asks to see lines ${from} to ${to} of ${file.path} ("${words.slice(0, 120)}"). They stay on this computer until you share them.` });
42
+ notify(`${nameOf(agent)} asks to see ${to - from + 1} lines of ${path.basename(file.path)}. Share or keep them in Harness.`, snippet);
43
+ return { ok: true, snippet: { id: snippet.id, path: file.path, from, to, state: "waiting" }, note: "The person decides whether to share these lines; you are told in the thread." };
44
+ };
45
+
46
+ /** The person shares or keeps a waiting snippet. */
47
+ const decide = (id, share) => {
48
+ const snippet = read(id);
49
+ if (!snippet) return { ok: false, error: "no such request" };
50
+ if (snippet.state !== "waiting") return { ok: false, error: `already ${snippet.state}` };
51
+ const done = { ...snippet, state: share ? "shared" : "kept", decidedAt: new Date().toISOString() };
52
+ save(done);
53
+ if (share) {
54
+ const post = store.roomPost({ goalId: snippet.thread, author: "harness", kind: "post", mentions: [snippet.agent],
55
+ text: `@${snippet.agent} the person shared lines ${snippet.from} to ${snippet.to} of ${snippet.path}${snippet.masked ? ` (${snippet.masked} secret${snippet.masked === 1 ? "" : "s"} in them stay masked)` : ""}:\n\n\`\`\`\n${snippet.text}\n\`\`\`` });
56
+ wake(snippet.thread, post, [snippet.agent]);
57
+ } else store.roomPost({ goalId: snippet.thread, author: "harness", kind: "note", text: `The person kept lines ${snippet.from} to ${snippet.to} of ${snippet.path} on this computer.` });
58
+ log(`snippet ${id}: ${done.state}`);
59
+ return { ok: true, snippet: { id, state: done.state } };
60
+ };
61
+
62
+ const list = ({ all = false } = {}) => JSON.parse(store.meta("snippets") || "[]").map(read).filter((snippet) => snippet && (all || snippet.state === "waiting"));
63
+ return { request, decide, list };
64
+ }