tryharness 0.7.23 → 0.7.26

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 +5268 -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 +384 -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/files.mjs CHANGED
@@ -1,13 +1,22 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { inflateRawSync } from "node:zlib";
3
6
 
4
7
  export function ensureDir(dir) {
5
8
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
6
- try { fs.chmodSync(dir, 0o700); } catch {}
9
+ try {
10
+ fs.chmodSync(dir, 0o700);
11
+ } catch {}
7
12
  }
8
13
 
9
14
  export function readJson(file, fallback = {}) {
10
- try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return fallback; }
15
+ try {
16
+ return JSON.parse(fs.readFileSync(file, "utf8"));
17
+ } catch {
18
+ return fallback;
19
+ }
11
20
  }
12
21
 
13
22
  export function writeJsonAtomic(file, value, mode = 0o600) {
@@ -15,12 +24,18 @@ export function writeJsonAtomic(file, value, mode = 0o600) {
15
24
  const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
16
25
  fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n", { mode });
17
26
  fs.renameSync(temporary, file);
18
- try { fs.chmodSync(file, mode); } catch {}
27
+ try {
28
+ fs.chmodSync(file, mode);
29
+ } catch {}
19
30
  }
20
31
 
21
32
  export function appendLog(file, message) {
22
33
  ensureDir(path.dirname(file));
23
- try { fs.appendFileSync(file, `${new Date().toISOString()} ${message}\n`, { mode: 0o600 }); } catch {}
34
+ try {
35
+ fs.appendFileSync(file, `${new Date().toISOString()} ${message}\n`, {
36
+ mode: 0o600,
37
+ });
38
+ } catch {}
24
39
  }
25
40
 
26
41
  export function listJsonl(root) {
@@ -29,7 +44,11 @@ export function listJsonl(root) {
29
44
  while (pending.length) {
30
45
  const dir = pending.pop();
31
46
  let entries = [];
32
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
47
+ try {
48
+ entries = fs.readdirSync(dir, { withFileTypes: true });
49
+ } catch {
50
+ continue;
51
+ }
33
52
  for (const entry of entries) {
34
53
  const full = path.join(dir, entry.name);
35
54
  if (entry.isDirectory()) pending.push(full);
@@ -46,15 +65,229 @@ export function listJsonl(root) {
46
65
 
47
66
  // A file's type from its name, for files an agent attaches without saying; anything unknown is plain bytes.
48
67
  const MIME_TYPES = {
49
- png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", heic: "image/heic", avif: "image/avif", bmp: "image/bmp", ico: "image/x-icon",
50
- pdf: "application/pdf", json: "application/json", zip: "application/zip", gz: "application/gzip", tar: "application/x-tar", wasm: "application/wasm", xml: "application/xml",
51
- docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
68
+ png: "image/png",
69
+ jpg: "image/jpeg",
70
+ jpeg: "image/jpeg",
71
+ gif: "image/gif",
72
+ webp: "image/webp",
73
+ svg: "image/svg+xml",
74
+ heic: "image/heic",
75
+ avif: "image/avif",
76
+ bmp: "image/bmp",
77
+ ico: "image/x-icon",
78
+ pdf: "application/pdf",
79
+ json: "application/json",
80
+ zip: "application/zip",
81
+ gz: "application/gzip",
82
+ tar: "application/x-tar",
83
+ wasm: "application/wasm",
84
+ xml: "application/xml",
85
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
86
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
52
87
  pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
53
- mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", ogg: "audio/ogg", flac: "audio/flac", mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm",
54
- txt: "text/plain", md: "text/markdown", csv: "text/csv", tsv: "text/tab-separated-values", html: "text/html", css: "text/css", log: "text/plain", diff: "text/x-diff", patch: "text/x-diff",
55
- js: "text/javascript", mjs: "text/javascript", ts: "text/x-typescript", tsx: "text/x-typescript", py: "text/x-python", rs: "text/x-rust", go: "text/x-go", sh: "text/x-shellscript",
56
- yaml: "text/yaml", yml: "text/yaml", toml: "text/x-toml", sql: "text/x-sql",
88
+ mp3: "audio/mpeg",
89
+ wav: "audio/wav",
90
+ m4a: "audio/mp4",
91
+ ogg: "audio/ogg",
92
+ flac: "audio/flac",
93
+ mp4: "video/mp4",
94
+ mov: "video/quicktime",
95
+ webm: "video/webm",
96
+ txt: "text/plain",
97
+ md: "text/markdown",
98
+ csv: "text/csv",
99
+ tsv: "text/tab-separated-values",
100
+ html: "text/html",
101
+ css: "text/css",
102
+ log: "text/plain",
103
+ diff: "text/x-diff",
104
+ patch: "text/x-diff",
105
+ js: "text/javascript",
106
+ mjs: "text/javascript",
107
+ ts: "text/x-typescript",
108
+ tsx: "text/x-typescript",
109
+ py: "text/x-python",
110
+ rs: "text/x-rust",
111
+ go: "text/x-go",
112
+ sh: "text/x-shellscript",
113
+ yaml: "text/yaml",
114
+ yml: "text/yaml",
115
+ toml: "text/x-toml",
116
+ sql: "text/x-sql",
57
117
  };
58
118
  export function mimeFor(name) {
59
- return MIME_TYPES[String(name).toLowerCase().split(".").pop()] || "application/octet-stream";
119
+ return (
120
+ MIME_TYPES[String(name).toLowerCase().split(".").pop()] ||
121
+ "application/octet-stream"
122
+ );
123
+ }
124
+
125
+ // The entries of a zip whose names pass `wanted`, as text (an Office file is zipped XML).
126
+ function zipEntries(bytes, wanted) {
127
+ const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
128
+ const entries = [];
129
+ for (
130
+ let at = end < 0 ? 0 : bytes.readUInt32LE(end + 16),
131
+ count = end < 0 ? 0 : bytes.readUInt16LE(end + 10);
132
+ count-- > 0 && bytes.readUInt32LE(at) === 0x02014b50;
133
+ ) {
134
+ const method = bytes.readUInt16LE(at + 10),
135
+ size = bytes.readUInt32LE(at + 20),
136
+ local = bytes.readUInt32LE(at + 42),
137
+ nameLength = bytes.readUInt16LE(at + 28);
138
+ const name = bytes.toString("utf8", at + 46, at + 46 + nameLength);
139
+ at +=
140
+ 46 +
141
+ nameLength +
142
+ bytes.readUInt16LE(at + 30) +
143
+ bytes.readUInt16LE(at + 32);
144
+ if (!wanted(name)) continue;
145
+ const start =
146
+ local +
147
+ 30 +
148
+ bytes.readUInt16LE(local + 26) +
149
+ bytes.readUInt16LE(local + 28),
150
+ raw = bytes.subarray(start, start + size);
151
+ entries.push({
152
+ name,
153
+ xml: (method === 8 ? inflateRawSync(raw) : raw).toString("utf8"),
154
+ });
155
+ }
156
+ return entries;
157
+ }
158
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
159
+ const unescapeXml = (text) =>
160
+ text.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (all, code) =>
161
+ code[0] !== "#"
162
+ ? (ENTITIES[code] ?? all)
163
+ : String.fromCodePoint(
164
+ code[1].toLowerCase() === "x"
165
+ ? parseInt(code.slice(2), 16)
166
+ : Number(code.slice(1))
167
+ )
168
+ );
169
+ // A Word or PowerPoint part's words: its text runs, a line per paragraph.
170
+ const runsOf = (xml, ns) =>
171
+ unescapeXml(
172
+ [
173
+ ...xml.matchAll(
174
+ new RegExp(
175
+ `<${ns}:t(?:\\s[^>]*)?>([^<]*)</${ns}:t>|<${ns}:tab/>|<${ns}:br/>|</${ns}:p>`,
176
+ "g"
177
+ )
178
+ ),
179
+ ]
180
+ .map(([tag, text]) => text ?? (tag.endsWith("tab/>") ? "\t" : "\n"))
181
+ .join("")
182
+ ).trim();
183
+ const numbered = (name) => Number(name.match(/(\d+)\.xml$/)?.[1] ?? 0);
184
+ // A spreadsheet's cells, a line per row and a tab between cells, sheet by sheet under its name.
185
+ function sheetsOf(entries) {
186
+ const xml = (name) => entries.find((entry) => entry.name === name)?.xml ?? "";
187
+ const shared = (
188
+ xml("xl/sharedStrings.xml").match(/<si\b[\s\S]*?<\/si>/g) ?? []
189
+ ).map((si) =>
190
+ [...si.matchAll(/<t(?:\s[^>]*)?>([^<]*)<\/t>/g)].map((t) => t[1]).join("")
191
+ );
192
+ const names = [
193
+ ...xml("xl/workbook.xml").matchAll(/<sheet\b[^>]*\bname="([^"]*)"/g),
194
+ ].map((sheet) => sheet[1]);
195
+ return entries
196
+ .filter((entry) => entry.name.startsWith("xl/worksheets/"))
197
+ .sort((a, b) => numbered(a.name) - numbered(b.name))
198
+ .map((sheet, index) => {
199
+ const rows = [
200
+ ...sheet.xml.matchAll(/<row\b[^>]*?(?:\/>|>([\s\S]*?)<\/row>)/g),
201
+ ].map((row) =>
202
+ [...(row[1] ?? "").matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)]
203
+ .map(([, attrs, inner = ""]) => {
204
+ // A formula saved without its result reads as the formula.
205
+ const type = attrs.match(/\bt="(\w+)"/)?.[1],
206
+ formula = inner.match(/<f\b[^>]*>([^<]*)<\/f>/)?.[1],
207
+ value =
208
+ inner.match(/<v>([^<]*)<\/v>/)?.[1] ??
209
+ (formula ? `=${formula}` : "");
210
+ return type === "s"
211
+ ? (shared[Number(value)] ?? "")
212
+ : type === "inlineStr"
213
+ ? [...inner.matchAll(/<t(?:\s[^>]*)?>([^<]*)<\/t>/g)]
214
+ .map((t) => t[1])
215
+ .join("")
216
+ : value;
217
+ })
218
+ .join("\t")
219
+ );
220
+ return `${names[index] ?? `Sheet ${index + 1}`}:\n${unescapeXml(rows.join("\n"))}`;
221
+ })
222
+ .join("\n\n");
223
+ }
224
+ // What Spotlight's importer read from the file, as it prints it (macOS's own PDF reader, no download needed).
225
+ const unescapeSpotlight = (text) =>
226
+ text.replace(/\\(U[0-9a-fA-F]{4}|[\s\S])/g, (all, code) =>
227
+ code.length === 5
228
+ ? String.fromCharCode(parseInt(code.slice(1), 16))
229
+ : ({ n: "\n", t: "\t", r: "\r" }[code] ?? code)
230
+ );
231
+ function pdfText(file, run) {
232
+ if (process.platform !== "darwin") {
233
+ const read = run("pdftotext", ["-layout", file, "-"], {
234
+ encoding: "utf8",
235
+ maxBuffer: 256 * 2 ** 20,
236
+ });
237
+ return read.status === 0 && read.stdout.trim() ? read.stdout.trim() : null;
238
+ }
239
+ // The importer goes by the file's extension.
240
+ const dir = /\.pdf$/i.test(file)
241
+ ? null
242
+ : fs.mkdtempSync(path.join(os.tmpdir(), "harness-pdf-"));
243
+ try {
244
+ if (dir) fs.copyFileSync(file, path.join(dir, "file.pdf"));
245
+ const read = run(
246
+ "mdimport",
247
+ ["-t", "-d3", dir ? path.join(dir, "file.pdf") : file],
248
+ { encoding: "utf8", maxBuffer: 256 * 2 ** 20 }
249
+ );
250
+ const found = /kMDItemTextContent = "((?:[^"\\]|\\.)*)"/.exec(
251
+ `${read.stdout}${read.stderr}`
252
+ );
253
+ return found ? unescapeSpotlight(found[1]).trim() : null;
254
+ } finally {
255
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
256
+ }
257
+ }
258
+ const OFFICE = {
259
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
260
+ parts: (name) => name === "word/document.xml",
261
+ text: (entries) =>
262
+ entries.map((entry) => runsOf(entry.xml, "w")).join("\n"),
263
+ },
264
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": {
265
+ parts: (name) => /^ppt\/slides\/slide\d+\.xml$/.test(name),
266
+ text: (entries) =>
267
+ entries
268
+ .sort((a, b) => numbered(a.name) - numbered(b.name))
269
+ .map(
270
+ (entry) => `Slide ${numbered(entry.name)}:\n${runsOf(entry.xml, "a")}`
271
+ )
272
+ .join("\n\n"),
273
+ },
274
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
275
+ parts: (name) =>
276
+ /^xl\/(sharedStrings|workbook|worksheets\/sheet\d+)\.xml$/.test(name),
277
+ text: sheetsOf,
278
+ },
279
+ };
280
+ export const isDocument = (mime) =>
281
+ mime === "application/pdf" || Object.hasOwn(OFFICE, mime);
282
+ /** A PDF's or a Word, Excel or PowerPoint file's words, read on this computer; null when it cannot be read here. */
283
+ export function documentText(file, mime, { run = spawnSync } = {}) {
284
+ try {
285
+ if (mime === "application/pdf") return pdfText(file, run);
286
+ const office = OFFICE[mime];
287
+ return office
288
+ ? office.text(zipEntries(fs.readFileSync(file), office.parts))
289
+ : null;
290
+ } catch {
291
+ return null;
292
+ }
60
293
  }
package/lib/firewall.mjs CHANGED
@@ -24,29 +24,63 @@ export const GUARD_HOLD = 2.5;
24
24
  export const GUARD_FLAG = 6.0;
25
25
  export const FALLBACK_FLAG = 1.6;
26
26
  // Lines worth asking the credential model about: an assignment, a header, or a long unbroken token.
27
- const CANDIDATE_LINE = /[=:]\s*\S{12,}|\b(?:bearer|authorization|token|secret|password|passwd|api[_-]?key|private[_-]?key)\b|[A-Za-z0-9_\-+/]{24,}/i;
27
+ const CANDIDATE_LINE =
28
+ /[=:]\s*\S{12,}|\b(?:bearer|authorization|token|secret|password|passwd|api[_-]?key|private[_-]?key)\b|[A-Za-z0-9_\-+/]{24,}/i;
28
29
  const TOKEN = /[^\s"'`,;()<>{}[\]]{12,}/g;
29
30
  const USERINFO = /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:([^\s@/]+)@/i;
30
- const ASSIGNED = /(?:[=:]|\b(?:is|key|token|password|secret|passwd)\b)\s*(["']?)([^\s"'`,;]{8,})\1/i;
31
+ const ASSIGNED =
32
+ /(?:[=:]|\b(?:is|key|token|password|secret|passwd)\b)\s*(["']?)([^\s"'`,;]{8,})\1/i;
31
33
 
32
34
  /** The secret itself on a flagged line: a URL's password, a token that mixes kinds of characters, or an assigned value. */
33
35
  // A public wallet address is not a secret, and work for Bankr needs it whole (the credential model scores a line holding
34
36
  // one as a credential; local-stack smoke 6b). An Ethereum or bech32 address always; base58 only where the line says it
35
37
  // is an address, since API keys of that length look the same. Private keys stay secrets: 64 hex, or base58 of 51 or more.
36
- const publicAddress = (token, line) => /^0x[0-9a-fA-F]{40}$/.test(token) || /^(?:bc1|tb1|ltc1)[a-z0-9]{25,62}$/.test(token)
37
- || (/^[1-9A-HJ-NP-Za-km-z]{26,44}$/.test(token) && /\b(?:address|wallet|solana|sol|btc|bitcoin|send|sent|pay|to)\b/i.test(line));
38
+ const publicAddress = (token, line) =>
39
+ /^0x[0-9a-fA-F]{40}$/.test(token) ||
40
+ /^(?:bc1|tb1|ltc1)[a-z0-9]{25,62}$/.test(token) ||
41
+ (/^[1-9A-HJ-NP-Za-km-z]{26,44}$/.test(token) &&
42
+ /\b(?:address|wallet|solana|sol|btc|bitcoin|send|sent|pay|to)\b/i.test(
43
+ line
44
+ ));
38
45
 
39
46
  export function secretSpan(line) {
40
47
  const url = line.match(USERINFO);
41
48
  if (url) return { at: url.index + url[0].lastIndexOf(url[1]), text: url[1] };
42
- const classes = (token) => [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(token)).length;
49
+ const classes = (token) =>
50
+ [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(token))
51
+ .length;
43
52
  // A NAME=value or name:value token keeps its name; only the value is the secret.
44
- const value = (token) => { const split = token.match(/^[A-Za-z_][\w.-]*[=:](.{8,})$/); return split ? split[1] : token; };
45
- const tokens = [...line.matchAll(TOKEN)].map((m) => ({ at: m.index, text: value(m[0].replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9=]+$/g, "")) }))
46
- .map((t) => ({ ...t, at: line.indexOf(t.text, t.at) })).filter((t) => t.text.length >= 12 && (classes(t.text) >= 3 || (t.text.length >= 24 && classes(t.text) >= 2)) && !/^[\w.-]+\.[a-z]{2,}$/i.test(t.text) && !/^(?:\/|\.\.?\/)/.test(t.text) && !publicAddress(t.text, line));
47
- if (tokens.length) return tokens.sort((a, b) => classes(b.text) - classes(a.text) || b.text.length - a.text.length)[0];
53
+ const value = (token) => {
54
+ const split = token.match(/^[A-Za-z_][\w.-]*[=:](.{8,})$/);
55
+ return split ? split[1] : token;
56
+ };
57
+ const tokens = [...line.matchAll(TOKEN)]
58
+ .map((m) => ({
59
+ at: m.index,
60
+ text: value(m[0].replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9=]+$/g, "")),
61
+ }))
62
+ .map((t) => ({ ...t, at: line.indexOf(t.text, t.at) }))
63
+ .filter(
64
+ (t) =>
65
+ t.text.length >= 12 &&
66
+ (classes(t.text) >= 3 ||
67
+ (t.text.length >= 24 && classes(t.text) >= 2)) &&
68
+ !/^[\w.-]+\.[a-z]{2,}$/i.test(t.text) &&
69
+ !/^(?:\/|\.\.?\/)/.test(t.text) &&
70
+ !publicAddress(t.text, line)
71
+ );
72
+ if (tokens.length)
73
+ return tokens.sort(
74
+ (a, b) =>
75
+ classes(b.text) - classes(a.text) || b.text.length - a.text.length
76
+ )[0];
48
77
  const assigned = line.match(ASSIGNED);
49
- return assigned ? { at: assigned.index + assigned[0].lastIndexOf(assigned[2]), text: assigned[2] } : null;
78
+ return assigned
79
+ ? {
80
+ at: assigned.index + assigned[0].lastIndexOf(assigned[2]),
81
+ text: assigned[2],
82
+ }
83
+ : null;
50
84
  }
51
85
 
52
86
  /** Overlapping windows over a long text, the start and end always included. */
@@ -54,22 +88,49 @@ export function windows(text, size, max = Math.ceil(24_000 / size)) {
54
88
  const value = String(text);
55
89
  if (value.length <= size) return [value];
56
90
  const out = [];
57
- for (let at = 0; at < value.length; at += size - Math.min(OVERLAP, size / 4)) out.push(value.slice(at, at + size));
91
+ for (let at = 0; at < value.length; at += size - Math.min(OVERLAP, size / 4))
92
+ out.push(value.slice(at, at + size));
58
93
  return out.length <= max ? out : [...out.slice(0, max - 2), ...out.slice(-2)];
59
94
  }
60
95
 
61
96
  export function createFirewall({ inference, log = () => {} }) {
62
- const margin = async (id, text, size, lane) => Math.max(...(await inference.logits(id, windows(text, size), { lane, maxLength: 640 })).map((row) => row[1] - row[0]));
97
+ const margin = async (id, text, size, lane) =>
98
+ Math.max(
99
+ ...(
100
+ await inference.logits(id, windows(text, size), {
101
+ lane,
102
+ maxLength: 640,
103
+ })
104
+ ).map((row) => row[1] - row[0])
105
+ );
63
106
  /** { score, holdScore, verdict: "clean" | "flag" | "hold" } for text one agent hands another. */
64
107
  const inspect = async (text, { lane = "handoff" } = {}) => {
65
- if (!String(text).trim()) return { score: -Infinity, holdScore: -Infinity, verdict: "clean" };
108
+ if (!String(text).trim())
109
+ return { score: -Infinity, holdScore: -Infinity, verdict: "clean" };
66
110
  try {
67
111
  const holdScore = await margin("guard", text, HOLD_WINDOW, lane);
68
- if (holdScore >= GUARD_HOLD) return { score: holdScore, holdScore, verdict: "hold" };
69
- const short = await margin("guard-short", text, FLAG_WINDOW, lane).catch((error) => { log(`firewall: the short-window guard could not run (${error.message})`); return null; });
70
- if (short !== null) return { score: short, holdScore, verdict: short >= GUARD_FLAG ? "flag" : "clean" };
112
+ if (holdScore >= GUARD_HOLD)
113
+ return { score: holdScore, holdScore, verdict: "hold" };
114
+ const short = await margin("guard-short", text, FLAG_WINDOW, lane).catch(
115
+ (error) => {
116
+ log(
117
+ `firewall: the short-window guard could not run (${error.message})`
118
+ );
119
+ return null;
120
+ }
121
+ );
122
+ if (short !== null)
123
+ return {
124
+ score: short,
125
+ holdScore,
126
+ verdict: short >= GUARD_FLAG ? "flag" : "clean",
127
+ };
71
128
  const score = await margin("guard", text, FALLBACK_WINDOW, lane);
72
- return { score, holdScore, verdict: score >= FALLBACK_FLAG ? "flag" : "clean" };
129
+ return {
130
+ score,
131
+ holdScore,
132
+ verdict: score >= FALLBACK_FLAG ? "flag" : "clean",
133
+ };
73
134
  } catch (error) {
74
135
  log(`firewall: the guard could not run (${error.message})`);
75
136
  return { score: null, verdict: "clean", unavailable: true };
@@ -81,21 +142,46 @@ export function createFirewall({ inference, log = () => {} }) {
81
142
  const value = String(text);
82
143
  const spans = [];
83
144
  let offset = 0;
84
- const lines = value.split("\n").map((line) => { const start = offset; offset += line.length + 1; return { line, start }; });
85
- const candidates = lines.filter(({ line }) => line.length <= 2_000 && (CANDIDATE_LINE.test(line) || secretReason(line)));
145
+ const lines = value.split("\n").map((line) => {
146
+ const start = offset;
147
+ offset += line.length + 1;
148
+ return { line, start };
149
+ });
150
+ const candidates = lines.filter(
151
+ ({ line }) =>
152
+ line.length <= 2_000 &&
153
+ (CANDIDATE_LINE.test(line) || secretReason(line))
154
+ );
86
155
  let probabilities = [];
87
156
  if (candidates.length) {
88
157
  try {
89
- probabilities = (await inference.logits("credentials", candidates.map(({ line }) => line), { lane, maxLength: 256 }))
158
+ probabilities = (
159
+ await inference.logits(
160
+ "credentials",
161
+ candidates.map(({ line }) => line),
162
+ { lane, maxLength: 256 }
163
+ )
164
+ )
90
165
  // The model's own calibration (policy.json): temperature 2.0954, threshold 0.74.
91
- .map((row) => { const a = row[0] / 2.0954, b = row[1] / 2.0954; return 1 / (1 + Math.exp(a - b)); });
92
- } catch (error) { log(`firewall: the credential model could not run (${error.message})`); }
166
+ .map((row) => {
167
+ const a = row[0] / 2.0954,
168
+ b = row[1] / 2.0954;
169
+ return 1 / (1 + Math.exp(a - b));
170
+ });
171
+ } catch (error) {
172
+ log(`firewall: the credential model could not run (${error.message})`);
173
+ }
93
174
  }
94
175
  candidates.forEach(({ line, start }, i) => {
95
176
  const flagged = (probabilities[i] ?? 0) >= 0.74 || secretReason(line);
96
177
  if (!flagged) return;
97
178
  const span = secretSpan(line);
98
- if (span) spans.push({ start: start + span.at, end: start + span.at + span.text.length, reason: secretReason(line) || "a credential" });
179
+ if (span)
180
+ spans.push({
181
+ start: start + span.at,
182
+ end: start + span.at + span.text.length,
183
+ reason: secretReason(line) || "a credential",
184
+ });
99
185
  });
100
186
  return spans.sort((a, b) => a.start - b.start);
101
187
  };
@@ -106,19 +192,38 @@ export function createFirewall({ inference, log = () => {} }) {
106
192
  * acts on the values), emails, phone numbers, wallet addresses and amounts become [EMAIL_n], [PHONE_n], [WALLET_n] and
107
193
  * [AMOUNT_n] too.
108
194
  */
109
- const scrub = async (text, { vault = new Map(), lane = "handoff", personal = false } = {}) => {
195
+ const scrub = async (
196
+ text,
197
+ { vault = new Map(), lane = "handoff", personal = false } = {}
198
+ ) => {
110
199
  const value = String(text);
111
200
  const found = await secrets(value, { lane });
112
- const spans = personal ? [...found, ...personalSpans(value).filter((span) => !found.some((other) => other.start < span.end && span.start < other.end))].sort((a, b) => a.start - b.start) : found;
201
+ const spans = personal
202
+ ? [
203
+ ...found,
204
+ ...personalSpans(value).filter(
205
+ (span) =>
206
+ !found.some(
207
+ (other) => other.start < span.end && span.start < other.end
208
+ )
209
+ ),
210
+ ].sort((a, b) => a.start - b.start)
211
+ : found;
113
212
  if (!spans.length) return { text: value, masked: 0, vault };
114
- const reverse = new Map([...vault].map(([placeholder, original]) => [original, placeholder]));
213
+ const reverse = new Map(
214
+ [...vault].map(([placeholder, original]) => [original, placeholder])
215
+ );
115
216
  let out = "";
116
217
  let at = 0;
117
218
  for (const span of spans) {
118
219
  if (span.start < at) continue;
119
220
  const original = value.slice(span.start, span.end);
120
221
  let placeholder = reverse.get(original);
121
- if (!placeholder) { placeholder = `[${LABELS[span.kind] ?? "SECRET"}_${vault.size + 1}]`; vault.set(placeholder, original); reverse.set(original, placeholder); }
222
+ if (!placeholder) {
223
+ placeholder = `[${LABELS[span.kind] ?? "SECRET"}_${vault.size + 1}]`;
224
+ vault.set(placeholder, original);
225
+ reverse.set(original, placeholder);
226
+ }
122
227
  out += value.slice(at, span.start) + placeholder;
123
228
  at = span.end;
124
229
  }
@@ -128,27 +233,56 @@ export function createFirewall({ inference, log = () => {} }) {
128
233
  return { inspect, secrets, scrub };
129
234
  }
130
235
 
131
- const LABELS = { email: "EMAIL", phone: "PHONE", wallet: "WALLET", money: "AMOUNT" };
236
+ const LABELS = {
237
+ email: "EMAIL",
238
+ phone: "PHONE",
239
+ wallet: "WALLET",
240
+ money: "AMOUNT",
241
+ };
132
242
 
133
243
  /** Puts a vault's originals back in place of its placeholders (only ever for text staying on this computer). */
134
244
  export function restore(text, vault) {
135
245
  let out = String(text);
136
- for (const [placeholder, original] of vault) out = out.split(placeholder).join(original);
246
+ for (const [placeholder, original] of vault)
247
+ out = out.split(placeholder).join(original);
137
248
  return out;
138
249
  }
139
250
 
140
251
  /** restore over every string in a JSON-like value. */
141
252
  export function restoreDeep(value, vault) {
142
253
  if (typeof value === "string") return restore(value, vault);
143
- if (Array.isArray(value)) return value.map((item) => restoreDeep(item, vault));
144
- if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, restoreDeep(item, vault)]));
254
+ if (Array.isArray(value))
255
+ return value.map((item) => restoreDeep(item, vault));
256
+ if (value && typeof value === "object")
257
+ return Object.fromEntries(
258
+ Object.entries(value).map(([key, item]) => [
259
+ key,
260
+ restoreDeep(item, vault),
261
+ ])
262
+ );
145
263
  return value;
146
264
  }
147
265
 
148
266
  /** Applies scrub to every string in a JSON-like value. */
149
- export async function scrubDeep(firewall, value, vault = new Map(), options = {}) {
150
- if (typeof value === "string") return (await firewall.scrub(value, { vault, ...options })).text;
151
- if (Array.isArray(value)) { const out = []; for (const item of value) out.push(await scrubDeep(firewall, item, vault, options)); return out; }
152
- if (value && typeof value === "object") { const out = {}; for (const [key, item] of Object.entries(value)) out[key] = await scrubDeep(firewall, item, vault, options); return out; }
267
+ export async function scrubDeep(
268
+ firewall,
269
+ value,
270
+ vault = new Map(),
271
+ options = {}
272
+ ) {
273
+ if (typeof value === "string")
274
+ return (await firewall.scrub(value, { vault, ...options })).text;
275
+ if (Array.isArray(value)) {
276
+ const out = [];
277
+ for (const item of value)
278
+ out.push(await scrubDeep(firewall, item, vault, options));
279
+ return out;
280
+ }
281
+ if (value && typeof value === "object") {
282
+ const out = {};
283
+ for (const [key, item] of Object.entries(value))
284
+ out[key] = await scrubDeep(firewall, item, vault, options);
285
+ return out;
286
+ }
153
287
  return value;
154
288
  }