tryharness 0.7.18 → 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/README.md +57 -14
- package/assets/opencode-plugin.js +47 -66
- package/assets/pi-extension.ts +91 -90
- package/cli.mjs +115 -9
- package/lib/adapters.mjs +19 -11
- package/lib/agentnode.mjs +154 -0
- package/lib/atlas.mjs +304 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +16 -4
- package/lib/context.mjs +290 -0
- package/lib/coordinator.mjs +27 -9
- package/lib/daemon.mjs +790 -93
- package/lib/digest.mjs +40 -0
- package/lib/environment.mjs +98 -0
- package/lib/firewall.mjs +154 -0
- package/lib/folders.mjs +161 -0
- package/lib/frames.mjs +159 -0
- package/lib/inference.mjs +326 -0
- package/lib/learn.mjs +120 -0
- package/lib/live.mjs +30 -5
- package/lib/localmodels.mjs +230 -0
- package/lib/mcp-stdio.mjs +107 -7
- package/lib/media.mjs +118 -0
- package/lib/members.json +1 -1
- package/lib/models.mjs +233 -0
- package/lib/network.mjs +118 -0
- package/lib/nudge.mjs +13 -5
- package/lib/ocr.mjs +217 -0
- package/lib/personal.mjs +39 -0
- package/lib/phones.mjs +31 -2
- package/lib/places.mjs +61 -0
- package/lib/receipts.mjs +42 -0
- package/lib/redact.mjs +334 -0
- package/lib/runner.mjs +37 -6
- package/lib/runtime-install.mjs +6 -5
- package/lib/screens.mjs +96 -0
- package/lib/sealed.mjs +3 -0
- package/lib/service.mjs +4 -2
- package/lib/snippets.mjs +64 -0
- package/lib/speech.mjs +480 -0
- package/lib/store.mjs +64 -5
- package/lib/swarm.mjs +89 -26
- package/lib/t3.mjs +7 -4
- package/lib/trails.mjs +177 -0
- package/lib/watches.mjs +163 -0
- package/lib/worktrees.mjs +21 -5
- package/package.json +6 -4
- package/lib/embedder.mjs +0 -156
package/lib/personal.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Personal details in text: an email, a phone number, a wallet address, an amount of money. The same patterns cover
|
|
2
|
+
// screenshots read by OCR (redact.mjs) and the words of the relay's decision-only requests to Harness (firewall.mjs).
|
|
3
|
+
|
|
4
|
+
// Wallet addresses: Ethereum-style 0x and 40 hex (OCR reads some 0s as Os), the shortened 0x1234…abcd form apps show,
|
|
5
|
+
// bech32 (bc1…), and base58 strings long and mixed enough to be addresses or keys rather than words or identifiers
|
|
6
|
+
// (Solana, Bitcoin legacy, base58 private keys), one misread character allowed. Hex alone is a hash, not an address,
|
|
7
|
+
// and a sha512-… integrity string is a hash in base64. OCR often runs a label into the value ("addressbc1q…"), so the
|
|
8
|
+
// prefixed forms need no boundary before them.
|
|
9
|
+
export const EVM = /[0O][xX][0-9a-fA-FOo]{38,42}(?![\w])/g;
|
|
10
|
+
export const EVM_SHORT = /0[xX][0-9a-fA-F]{3,8}(?:…|\.{2,3})[0-9a-fA-F]{3,8}(?![\w])/g;
|
|
11
|
+
export const BECH32 = /(?:bc1|tb1|ltc1)[a-zA-Z0-9]{20,90}(?![\w])/g;
|
|
12
|
+
export const BASE58ISH = /(?<![\w])[0-9A-Za-z]{25,90}(?![\w])/g;
|
|
13
|
+
export const base58 = (token) => (token.match(/[0OIl]/g) || []).length <= 1 && (token.match(/\d/g) || []).length >= 2 && (token.match(/[A-Z]/g) || []).length >= 3 && (token.match(/[a-z]/g) || []).length >= 3 && /[g-zG-Z]/.test(token);
|
|
14
|
+
// Money: an amount with a currency sign or code (a lone digit after $ is a shell variable, not money).
|
|
15
|
+
const CODES = "USD|USDC|USDT|EUR|GBP|JPY|CHF|CAD|AUD|ETH|WETH|stETH|BTC|WBTC|cbBTC|SOL|DAI|BNB|MATIC|POL|ARB|OP|AVAX|DOGE|XRP|ADA|DOT|LINK|UNI|AAVE|DEGEN";
|
|
16
|
+
const AMOUNT = "\\d{1,3}(?:[,']\\d{3})+(?:[.,]\\d+)?|\\d+(?:[.,]\\d+)?";
|
|
17
|
+
export const MONEY = new RegExp(`(?<![\\w$€£¥])(?:[-+−]\\s?)?(?:(?:US)?[$€£¥₹₩₿Ξ]\\s?(?:${AMOUNT})(?:\\s?[KMBkmb](?![A-Za-z]))?|(?:${AMOUNT})(?:\\s?[KMBkmb](?=\\s?(?:${CODES})))?\\s?(?:(?:${CODES})(?![A-Za-z])|[€£₿Ξ]))`, "g");
|
|
18
|
+
export const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g;
|
|
19
|
+
// Phone numbers: digit groups joined by single spaces, dots or dashes, with an optional +country code and (area code);
|
|
20
|
+
// 10 to 15 digits (8 with a country code). Dates, times, IP addresses and version numbers do not qualify.
|
|
21
|
+
export const PHONE = /(?<![\w.+/:#-])(?:\+\d{1,3}[ .-]?)?(?:\(\d{1,4}\)[ .-]?)?\d{2,5}(?:[ .-]\d{2,6}){1,4}(?![\w])/g;
|
|
22
|
+
export const phoneLike = (text) => {
|
|
23
|
+
const digits = text.replace(/\D/g, "").length;
|
|
24
|
+
return digits <= 15 && digits >= (text.startsWith("+") ? 8 : 10) && !/^\d{4}[-.]\d{1,2}[-.]\d{1,2}/.test(text) && !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(text);
|
|
25
|
+
};
|
|
26
|
+
/** Personal details in plain text: [{ start, end, kind }] (kind: wallet, email, phone, money), longest first where they overlap. */
|
|
27
|
+
export function personalSpans(text) {
|
|
28
|
+
const value = String(text);
|
|
29
|
+
const found = [];
|
|
30
|
+
const take = (pattern, kind, keep = () => true) => { for (const m of value.matchAll(pattern)) if (keep(m[0], value.slice(0, m.index))) found.push({ start: m.index, end: m.index + m[0].length, kind }); };
|
|
31
|
+
take(EVM, "wallet"); take(EVM_SHORT, "wallet"); take(BECH32, "wallet");
|
|
32
|
+
take(BASE58ISH, "wallet", (token, before) => base58(token) && !/sha\d+[-:][A-Za-z0-9+/]*$/i.test(before));
|
|
33
|
+
take(EMAIL, "email", (token, before) => !/^git@/i.test(token) && !/:\/\/\S*$/.test(before));
|
|
34
|
+
take(PHONE, "phone", phoneLike);
|
|
35
|
+
take(MONEY, "money", (token) => !/^[-+−]?\s?(?:US)?[$€£¥₹₩₿Ξ]\s?\d$/.test(token));
|
|
36
|
+
const kept = [];
|
|
37
|
+
for (const span of found.sort((a, b) => (b.end - b.start) - (a.end - a.start))) if (!kept.some((other) => other.start < span.end && span.start < other.end)) kept.push(span);
|
|
38
|
+
return kept.sort((a, b) => a.start - b.start);
|
|
39
|
+
}
|
package/lib/phones.mjs
CHANGED
|
@@ -22,8 +22,12 @@ const PHONE_COMMANDS = new Set([
|
|
|
22
22
|
"board.list", "board.get", "board.children", "board.agents", "board.post", "board.run", "board.release", "board.answer", "board.remove",
|
|
23
23
|
"board.thread.read", "board.thread.post", "board.thread.send", "board.thread.discard", "board.thread.auto", "board.thread.end",
|
|
24
24
|
"board.goal.list", "board.goal.get", "board.goal.stop", "board.room.read",
|
|
25
|
+
// A thread's files: shown, opened and downloaded, and shared or kept on this computer, by the person.
|
|
26
|
+
"board.file.get", "board.file.share",
|
|
25
27
|
"board.swarm.list", "board.swarm.read", "board.swarm.start", "board.swarm.post", "board.swarm.stop", "board.swarm.stop_all", "board.swarm.resume", "board.swarm.folder", "board.swarm.coordinator",
|
|
26
28
|
"board.swarm.diff", "board.swarm.merge", "board.swarm.discard", "board.swarm.start_run", "board.swarm.agents", "board.swarm.note",
|
|
29
|
+
// Where a thread works: the folders offered, any folder browsed to, and where a message would work (folders.mjs).
|
|
30
|
+
"board.swarm.places", "board.swarm.browse", "board.swarm.place",
|
|
27
31
|
// The person's own running sessions, read and messaged as from the desktop app; never queued (a session may be gone).
|
|
28
32
|
"board.live", "board.live.read", "board.live.say",
|
|
29
33
|
]);
|
|
@@ -33,7 +37,9 @@ const HARNESS_COMMANDS = new Set([
|
|
|
33
37
|
"board.goal.create", "board.goal.get", "board.goal.list", "board.goal.finish", "board.room.read", "board.room.post", "board.room.search", "board.children", "board.agents",
|
|
34
38
|
"board.swarm.list", "board.swarm.read", "board.swarm.diff",
|
|
35
39
|
]);
|
|
36
|
-
|
|
40
|
+
// Swarms talking to swarms (network.mjs), from the Harness window: the person's own, never Harness's.
|
|
41
|
+
const RELAY_PERSON_COMMANDS = ["board.relay.list", "board.relay.network", "board.relay.decide", "board.relay.request", "board.relay.seen", "board.relay.defaults", "board.relay.peers"];
|
|
42
|
+
const BROWSER_PERSON_COMMANDS = new Set([...PHONE_COMMANDS, "board.goal.resume", "board.standing", "board.standing.check", "board.standing.off", ...RELAY_PERSON_COMMANDS]);
|
|
37
43
|
const BROWSER_READS = new Set(["sessions.search", "sessions.read", "sessions.raw", "tasks.status", "relay.request", "relay.open"]);
|
|
38
44
|
|
|
39
45
|
export async function createPhoneDesk({ store, identityFile, boardCommand, reads = {}, version, onMessage = () => {}, machine = () => null }) {
|
|
@@ -145,10 +151,33 @@ export async function createPhoneDesk({ store, identityFile, boardCommand, reads
|
|
|
145
151
|
// Harness's bridge message: a pairing or a sealed call. Everything returned is safe to pass through Harness.
|
|
146
152
|
const handle = async (input) => (input?.kind === "pair" ? pair(input) : input?.kind === "call" ? call(input.envelope) : { ok: false, error: "unknown sealed request" });
|
|
147
153
|
|
|
154
|
+
// The other way round: this computer asking the person's own Harness window for what only the window holds (its
|
|
155
|
+
// screen history). The question is sealed to every browser linked here ("d2b") under one fresh request id; Harness
|
|
156
|
+
// hands the envelopes to the window that is open (`carry`, the bridge's leg), only a browser holding one of these
|
|
157
|
+
// links can open its envelope, and its answer comes back sealed ("b2d") for this request id alone. Harness sees which
|
|
158
|
+
// devices, sizes and timing, never the question or the answer.
|
|
159
|
+
const ask = async (call, carry) => {
|
|
160
|
+
const browsers = store.phones().filter((device) => device.kind === "browser" && !device.revoked_at).slice(0, 8).map((device) => store.getPhone(device.id));
|
|
161
|
+
if (!browsers.length) return { ok: false, code: "unpaired", error: "no Harness window is linked to this computer: the person links one with npx tryharness link" };
|
|
162
|
+
const requestId = b64u(randomBytes(16));
|
|
163
|
+
const ts = Date.now();
|
|
164
|
+
const envelopes = await Promise.all(browsers.map(async (device) => seal(await keyFor(device), "d2b", device.id, requestId, { ts, call })));
|
|
165
|
+
const reply = await carry(envelopes);
|
|
166
|
+
if (reply?.window === "closed") return { ok: false, code: "closed", error: "the person's Harness window isn't open: ask them to open Harness, then try again" };
|
|
167
|
+
if (reply?.window !== "open") return { ok: false, error: reply?.error || "Harness could not carry the question" };
|
|
168
|
+
if (!reply.ok || !reply.envelope) return { ok: false, code: reply.code, error: reply.code === "window" ? `the open Harness window could not answer: ${reply.error}` : reply.error };
|
|
169
|
+
const device = browsers.find((item) => item.id === reply.envelope.deviceId);
|
|
170
|
+
if (!device || reply.envelope.requestId !== requestId) return { ok: false, code: "unreadable", error: "the answer was for another question" };
|
|
171
|
+
let answer;
|
|
172
|
+
try { answer = await open(await keyFor(device), "b2d", reply.envelope); } catch { return { ok: false, code: "unreadable", error: "the answer could not be opened: it did not come from a window linked to this computer" }; }
|
|
173
|
+
store.touchPhone(device.id);
|
|
174
|
+
return answer;
|
|
175
|
+
};
|
|
176
|
+
|
|
148
177
|
const revoke = (id) => {
|
|
149
178
|
keys.delete(String(id));
|
|
150
179
|
return store.revokePhone(String(id));
|
|
151
180
|
};
|
|
152
181
|
|
|
153
|
-
return { offer, handle, revoke, list: () => store.phones(), publicKey: identity.publicKey, identity };
|
|
182
|
+
return { offer, handle, ask, revoke, list: () => store.phones(), publicKey: identity.publicKey, identity };
|
|
154
183
|
}
|
package/lib/places.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { paths } from "./paths.mjs";
|
|
7
|
+
import { agentEnvironment } from "./agentnode.mjs";
|
|
6
8
|
import { mainCheckout } from "./worktrees.mjs";
|
|
7
9
|
|
|
8
10
|
// Where a coding session runs, and how it opens there again. A session's place comes from its own environment: the
|
|
@@ -168,6 +170,43 @@ export function resumeLine(agent, sessionId, folder = null) {
|
|
|
168
170
|
return `${folder && path.isAbsolute(folder) ? `cd ${quote(folder)} && ` : ""}${resume} ${sessionId}`;
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
/** Whether an editor runs a workspace's tasks when its window opens: it asks the person once, unless they turned that off. */
|
|
174
|
+
export function editorRunsOpenTasks(editorId) {
|
|
175
|
+
const editor = editorById(editorId);
|
|
176
|
+
if (!editor) return false;
|
|
177
|
+
let settings = "";
|
|
178
|
+
try { settings = fs.readFileSync(path.join(editorData(editor), "User", "settings.json"), "utf8"); } catch { return true; }
|
|
179
|
+
return !/"task\.allowAutomaticTasks"\s*:\s*"off"/.test(settings);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A session to resume in an editor's own terminal: a workspace on the folder the editor opens around it (`window`), whose
|
|
184
|
+
* task resumes the session in a terminal when the window opens (runOn folderOpen; the editor asks once to trust the folder
|
|
185
|
+
* and to allow such tasks). Through the person's login shell, so the agent runs with their own setup. The file lives in
|
|
186
|
+
* Harness's folder, never in the person's; its path, or null when the agent has no resume command.
|
|
187
|
+
*/
|
|
188
|
+
export function editorSessionWorkspace(agent, sessionId, folder, window = folder) {
|
|
189
|
+
const resume = AGENTS[agent]?.resume;
|
|
190
|
+
if (!resume || !PLAIN_ID.test(String(sessionId || "")) || !folder || !path.isAbsolute(folder)) return null;
|
|
191
|
+
// Through the person's login shell (VS Code's default terminal on Windows), with a Node the agent accepts first.
|
|
192
|
+
const shell = process.platform === "win32" ? null : process.env.SHELL || os.userInfo().shell || "/bin/sh";
|
|
193
|
+
const { bin } = agentEnvironment(AGENTS[agent].command);
|
|
194
|
+
const name = `${path.basename(window || folder)} - ${agent} ${String(sessionId).replace(/^ses_/, "").slice(0, 8)}`.replace(/[^A-Za-z0-9 ._-]/g, "-");
|
|
195
|
+
const file = path.join(paths.home, "editor", `${name}.code-workspace`);
|
|
196
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
197
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
198
|
+
folders: [{ path: window || folder }],
|
|
199
|
+
tasks: { version: "2.0.0", tasks: [{
|
|
200
|
+
label: `${agent} session ${sessionId}`, type: "shell", command: `${resume} ${sessionId}`,
|
|
201
|
+
options: { cwd: folder, ...(shell ? { shell: { executable: shell, args: ["-l", "-i", "-c"] } } : {}), ...(bin ? { env: { PATH: `${bin}${path.delimiter}\${env:PATH}` } } : {}) },
|
|
202
|
+
runOptions: { runOn: "folderOpen" },
|
|
203
|
+
presentation: { reveal: "always", focus: true, panel: "dedicated", clear: true },
|
|
204
|
+
problemMatcher: [],
|
|
205
|
+
}] },
|
|
206
|
+
}, null, 2), { mode: 0o600 });
|
|
207
|
+
return file;
|
|
208
|
+
}
|
|
209
|
+
|
|
171
210
|
/** A session in its agent's own panel in an editor (Claude Code's, OpenAI's Codex extension), when it is installed there. */
|
|
172
211
|
export function panelLink(agent, editorId, sessionId) {
|
|
173
212
|
const panel = AGENTS[agent]?.panel;
|
|
@@ -235,6 +274,28 @@ export function t3Project(folder, home = t3Home()) {
|
|
|
235
274
|
return found ? { id: found[0], root: realDir(found[1]) } : null;
|
|
236
275
|
}
|
|
237
276
|
|
|
277
|
+
/**
|
|
278
|
+
* T3 Code's thread for a folder, so T3 opens on the right workspace: the newest one in that worktree, else the newest
|
|
279
|
+
* working in its project's own folder when the folder is that, else the newest in its project.
|
|
280
|
+
*/
|
|
281
|
+
export function t3ThreadIn(folder, home = t3Home()) {
|
|
282
|
+
const dir = folder ? realDir(folder) : null;
|
|
283
|
+
if (!dir || !t3Tables(home).has("orchestration_v2_projection_threads")) return null;
|
|
284
|
+
const project = t3Project(folder, home);
|
|
285
|
+
const rows = (t3Query(home, "select thread_id || char(9) || coalesce(project_id, '') || char(9) || coalesce(json_extract(payload_json, '$.worktreePath'), '') from orchestration_v2_projection_threads where deleted_at is null and archived_at is null order by updated_at desc;") || "")
|
|
286
|
+
.split("\n").map((line) => line.split("\t")).filter(([id]) => id);
|
|
287
|
+
const inProject = project ? rows.filter(([, id]) => id === project.id) : [];
|
|
288
|
+
const found = rows.find(([, , tree]) => tree && realDir(tree) === dir)
|
|
289
|
+
?? (dir === project?.root ? inProject.find(([, , tree]) => !tree) : null) ?? inProject[0];
|
|
290
|
+
return found?.[0] ?? null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Every folder T3 Code has a project for. */
|
|
294
|
+
export function t3Projects(home = t3Home()) {
|
|
295
|
+
if (!t3Tables(home).has("projection_projects")) return [];
|
|
296
|
+
return (t3Query(home, "select workspace_root from projection_projects where deleted_at is null;") || "").split("\n").map(realDir).filter(Boolean);
|
|
297
|
+
}
|
|
298
|
+
|
|
238
299
|
/** The model (and its options) the person last ran an agent with in T3 Code, as T3 records each thread's choice. */
|
|
239
300
|
export function t3Model(agent, home = t3Home()) {
|
|
240
301
|
const provider = t3Provider(agent);
|
package/lib/receipts.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { insideRoot, isTestCommand } from "./trails.mjs";
|
|
2
|
+
|
|
3
|
+
// Receipts for agents' work: what a run's own session file shows it did after its last change, never what it says it
|
|
4
|
+
// did. A run that changed files and then ran its tests with a zero exit is "tested"; one whose last test after its last
|
|
5
|
+
// change failed is "tests failing"; one that changed files and ran no test after the change is "not tested after its
|
|
6
|
+
// last change". The words come from code over the transcript, so an agent cannot talk its way to "tested". Changes are
|
|
7
|
+
// code changes inside the run's folder: notes, docs and files elsewhere (an agent's own memory) do not need tests.
|
|
8
|
+
|
|
9
|
+
const DOC = /\.(?:md|mdx|txt|rst)$/i;
|
|
10
|
+
|
|
11
|
+
/** { verdict, changed, lastChangeAt, tests: [{ command, ok, at, afterChange }] } for a trail (trails.mjs). */
|
|
12
|
+
export function receipt(trail, { root = trail?.cwd } = {}) {
|
|
13
|
+
const calls = trail?.calls ?? [];
|
|
14
|
+
const code = (call) => call.edited.map((token) => (root ? insideRoot(token, root, trail.cwd) : token)).filter((file) => file && !DOC.test(file));
|
|
15
|
+
let lastChange = -1;
|
|
16
|
+
calls.forEach((call, i) => { if (code(call).length) lastChange = i; });
|
|
17
|
+
const changed = [...new Set(calls.flatMap(code))];
|
|
18
|
+
const tests = calls.map((call, i) => ({ call, i })).filter(({ call }) => call.command && isTestCommand(call.command))
|
|
19
|
+
.map(({ call, i }) => ({ command: call.command.split("\n")[0].slice(0, 200), ok: !call.error, at: call.at, afterChange: i > lastChange }));
|
|
20
|
+
const after = tests.filter((test) => test.afterChange);
|
|
21
|
+
const verdict = !changed.length ? "no changes" : !after.length ? "not tested after its last change" : after.at(-1).ok ? "tested" : "tests failing";
|
|
22
|
+
// The same test failing again and again is the clearest sign of a run going round in circles.
|
|
23
|
+
let repeated = 0;
|
|
24
|
+
let streak = 0;
|
|
25
|
+
tests.forEach((test, i) => { streak = !test.ok && i > 0 && !tests[i - 1].ok && tests[i - 1].command === test.command ? streak + 1 : test.ok ? 0 : 1; repeated = Math.max(repeated, streak); });
|
|
26
|
+
return { verdict, changed, lastChangeAt: lastChange >= 0 ? calls[lastChange].at : null, tests: after.length ? after : tests.slice(-3), repeatedFailures: repeated };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The line the thread shows for a receipt. */
|
|
30
|
+
export function receiptLine(agentName, result) {
|
|
31
|
+
const line = verdictLine(agentName, result);
|
|
32
|
+
return result.repeatedFailures >= 3 ? `${line} The same test failed ${result.repeatedFailures} times in a row during the run.` : line;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function verdictLine(agentName, result) {
|
|
36
|
+
const files = `${result.changed.length} file${result.changed.length === 1 ? "" : "s"}`;
|
|
37
|
+
if (result.verdict === "no changes") return `Receipt: ${agentName} changed no files in this run.`;
|
|
38
|
+
const last = result.tests.filter((test) => test.afterChange).at(-1);
|
|
39
|
+
if (result.verdict === "tested") return `Receipt: ${agentName} changed ${files}, then ran \`${last.command}\` and it passed.`;
|
|
40
|
+
if (result.verdict === "tests failing") return `Receipt: ${agentName} changed ${files}; its last test after the change, \`${last.command}\`, failed.`;
|
|
41
|
+
return `Receipt: ${agentName} changed ${files} and ran no tests after its last change.`;
|
|
42
|
+
}
|
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
|
+
}
|