tryharness 0.7.22 → 0.7.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +48 -8
  2. package/assets/opencode-plugin.js +48 -10
  3. package/assets/pi-extension.ts +106 -26
  4. package/cli.mjs +1281 -289
  5. package/lib/activity.mjs +197 -40
  6. package/lib/adapters.mjs +337 -54
  7. package/lib/agentnode.mjs +175 -35
  8. package/lib/atlas.mjs +604 -0
  9. package/lib/bip39-english.txt +2048 -0
  10. package/lib/cards.mjs +177 -39
  11. package/lib/claudemem.mjs +74 -18
  12. package/lib/commands.mjs +13 -5
  13. package/lib/context.mjs +628 -0
  14. package/lib/control-client.mjs +5 -2
  15. package/lib/coordinator.mjs +281 -56
  16. package/lib/daemon.mjs +5590 -838
  17. package/lib/desktop.mjs +149 -35
  18. package/lib/digest.mjs +81 -0
  19. package/lib/environment.mjs +180 -0
  20. package/lib/files.mjs +246 -13
  21. package/lib/firewall.mjs +288 -0
  22. package/lib/folders.mjs +223 -57
  23. package/lib/frames.mjs +333 -0
  24. package/lib/inference.mjs +663 -0
  25. package/lib/learn.mjs +182 -0
  26. package/lib/live.mjs +286 -61
  27. package/lib/localmodels.mjs +571 -0
  28. package/lib/mcp-stdio.mjs +912 -103
  29. package/lib/media.mjs +228 -0
  30. package/lib/members.json +1 -1
  31. package/lib/members.mjs +35 -9
  32. package/lib/models.mjs +342 -80
  33. package/lib/network.mjs +201 -42
  34. package/lib/notify.mjs +49 -12
  35. package/lib/nudge.mjs +190 -32
  36. package/lib/ocr.mjs +380 -0
  37. package/lib/onboard.mjs +321 -70
  38. package/lib/parsers.mjs +132 -41
  39. package/lib/paths.mjs +33 -10
  40. package/lib/personal.mjs +77 -0
  41. package/lib/phones.mjs +398 -51
  42. package/lib/places.mjs +566 -102
  43. package/lib/provenance.mjs +19 -4
  44. package/lib/receipts.mjs +82 -0
  45. package/lib/redact.mjs +730 -0
  46. package/lib/runner.mjs +347 -55
  47. package/lib/runtime-install.mjs +59 -17
  48. package/lib/screens.mjs +212 -0
  49. package/lib/sealed.mjs +160 -28
  50. package/lib/secrets.mjs +18 -5
  51. package/lib/service.mjs +173 -35
  52. package/lib/snippets.mjs +140 -0
  53. package/lib/speech.mjs +993 -0
  54. package/lib/store.mjs +1678 -301
  55. package/lib/swarm.mjs +598 -117
  56. package/lib/t3.mjs +400 -79
  57. package/lib/trails.mjs +401 -0
  58. package/lib/ui.mjs +95 -23
  59. package/lib/vectors.mjs +5 -2
  60. package/lib/wakes.mjs +284 -68
  61. package/lib/watches.mjs +362 -0
  62. package/lib/worktrees.mjs +186 -38
  63. package/package.json +6 -4
  64. package/lib/embedder.mjs +0 -156
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
  }
@@ -0,0 +1,288 @@
1
+ import { secretReason } from "./secrets.mjs";
2
+ import { personalSpans } from "./personal.mjs";
3
+
4
+ // The handoff firewall: two small local models on the text agents pass each other. Inbound, an injection guard reads
5
+ // what an agent, page, tool or file hands another agent and flags or holds words that try to give orders. Outbound, a
6
+ // credential model joins the room's patterns (secrets.mjs) and swaps any key or password in a brief bound for a hosted
7
+ // agent for a placeholder, restored when the answer comes back to this computer. Neither model decides anything about
8
+ // money or permissions: those stay code-enforced. Thresholds come from the handoff-firewall eval (measured on this
9
+ // computer's own threads); a guard that cannot run leaves the text as it was, with the patterns still applied.
10
+
11
+ const OVERLAP = 200;
12
+ // A guard's score is its logit margin (injection minus benign), the scale its card calibrates on; the maximum over
13
+ // windows stands for the whole text. Measured on this computer's own threads (handoff-firewall eval, October 14): small
14
+ // windows find a sentence planted in a long page, large ones almost never hold a harmless post. A hold (the person must
15
+ // release it) is Wolf Defender over 1,800-character windows at 2.5: no harmless post held, 90% of standalone attacks
16
+ // held. A flag (a label every reader sees) is a hold or Horizon over 300-character windows at 6.0: 3.4% of real
17
+ // harmless posts and pages flagged, 83% of planted and all standalone attacks caught (84% planted at 4.4% flagged with
18
+ // the thresholds chosen on one half of the attacks and checked on the other). Without Horizon, Wolf Defender flags alone
19
+ // over 400-character windows at 1.6 (3.9% flagged, 63% planted, 95% standalone).
20
+ const HOLD_WINDOW = 1_800;
21
+ const FLAG_WINDOW = 300;
22
+ const FALLBACK_WINDOW = 400;
23
+ export const GUARD_HOLD = 2.5;
24
+ export const GUARD_FLAG = 6.0;
25
+ export const FALLBACK_FLAG = 1.6;
26
+ // Lines worth asking the credential model about: an assignment, a header, or a long unbroken token.
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;
29
+ const TOKEN = /[^\s"'`,;()<>{}[\]]{12,}/g;
30
+ const USERINFO = /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:([^\s@/]+)@/i;
31
+ const ASSIGNED =
32
+ /(?:[=:]|\b(?:is|key|token|password|secret|passwd)\b)\s*(["']?)([^\s"'`,;]{8,})\1/i;
33
+
34
+ /** The secret itself on a flagged line: a URL's password, a token that mixes kinds of characters, or an assigned value. */
35
+ // A public wallet address is not a secret, and work for Bankr needs it whole (the credential model scores a line holding
36
+ // one as a credential; local-stack smoke 6b). An Ethereum or bech32 address always; base58 only where the line says it
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.
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
+ ));
45
+
46
+ export function secretSpan(line) {
47
+ const url = line.match(USERINFO);
48
+ if (url) return { at: url.index + url[0].lastIndexOf(url[1]), text: url[1] };
49
+ const classes = (token) =>
50
+ [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(token))
51
+ .length;
52
+ // A NAME=value or name:value token keeps its name; only the value is the secret.
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];
77
+ const assigned = line.match(ASSIGNED);
78
+ return assigned
79
+ ? {
80
+ at: assigned.index + assigned[0].lastIndexOf(assigned[2]),
81
+ text: assigned[2],
82
+ }
83
+ : null;
84
+ }
85
+
86
+ /** Overlapping windows over a long text, the start and end always included. */
87
+ export function windows(text, size, max = Math.ceil(24_000 / size)) {
88
+ const value = String(text);
89
+ if (value.length <= size) return [value];
90
+ const out = [];
91
+ for (let at = 0; at < value.length; at += size - Math.min(OVERLAP, size / 4))
92
+ out.push(value.slice(at, at + size));
93
+ return out.length <= max ? out : [...out.slice(0, max - 2), ...out.slice(-2)];
94
+ }
95
+
96
+ export function createFirewall({ inference, log = () => {} }) {
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
+ );
106
+ /** { score, holdScore, verdict: "clean" | "flag" | "hold" } for text one agent hands another. */
107
+ const inspect = async (text, { lane = "handoff" } = {}) => {
108
+ if (!String(text).trim())
109
+ return { score: -Infinity, holdScore: -Infinity, verdict: "clean" };
110
+ try {
111
+ const holdScore = await margin("guard", text, HOLD_WINDOW, lane);
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
+ };
128
+ const score = await margin("guard", text, FALLBACK_WINDOW, lane);
129
+ return {
130
+ score,
131
+ holdScore,
132
+ verdict: score >= FALLBACK_FLAG ? "flag" : "clean",
133
+ };
134
+ } catch (error) {
135
+ log(`firewall: the guard could not run (${error.message})`);
136
+ return { score: null, verdict: "clean", unavailable: true };
137
+ }
138
+ };
139
+
140
+ /** Secret spans in a text: [{ start, end, reason }], from the patterns and the credential model. */
141
+ const secrets = async (text, { lane = "handoff" } = {}) => {
142
+ const value = String(text);
143
+ const spans = [];
144
+ let offset = 0;
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
+ );
155
+ let probabilities = [];
156
+ if (candidates.length) {
157
+ try {
158
+ probabilities = (
159
+ await inference.logits(
160
+ "credentials",
161
+ candidates.map(({ line }) => line),
162
+ { lane, maxLength: 256 }
163
+ )
164
+ )
165
+ // The model's own calibration (policy.json): temperature 2.0954, threshold 0.74.
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
+ }
174
+ }
175
+ candidates.forEach(({ line, start }, i) => {
176
+ const flagged = (probabilities[i] ?? 0) >= 0.74 || secretReason(line);
177
+ if (!flagged) return;
178
+ const span = secretSpan(line);
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
+ });
185
+ });
186
+ return spans.sort((a, b) => a.start - b.start);
187
+ };
188
+
189
+ /**
190
+ * The text with every secret replaced by [SECRET_n]; vault maps placeholders back. Restore with restore(text, vault).
191
+ * A vault passed in keeps placeholders stable across a thread. With personal set (a request that only decides, nothing
192
+ * acts on the values), emails, phone numbers, wallet addresses and amounts become [EMAIL_n], [PHONE_n], [WALLET_n] and
193
+ * [AMOUNT_n] too.
194
+ */
195
+ const scrub = async (
196
+ text,
197
+ { vault = new Map(), lane = "handoff", personal = false } = {}
198
+ ) => {
199
+ const value = String(text);
200
+ const found = await secrets(value, { lane });
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;
212
+ if (!spans.length) return { text: value, masked: 0, vault };
213
+ const reverse = new Map(
214
+ [...vault].map(([placeholder, original]) => [original, placeholder])
215
+ );
216
+ let out = "";
217
+ let at = 0;
218
+ for (const span of spans) {
219
+ if (span.start < at) continue;
220
+ const original = value.slice(span.start, span.end);
221
+ let placeholder = reverse.get(original);
222
+ if (!placeholder) {
223
+ placeholder = `[${LABELS[span.kind] ?? "SECRET"}_${vault.size + 1}]`;
224
+ vault.set(placeholder, original);
225
+ reverse.set(original, placeholder);
226
+ }
227
+ out += value.slice(at, span.start) + placeholder;
228
+ at = span.end;
229
+ }
230
+ return { text: out + value.slice(at), masked: spans.length, vault };
231
+ };
232
+
233
+ return { inspect, secrets, scrub };
234
+ }
235
+
236
+ const LABELS = {
237
+ email: "EMAIL",
238
+ phone: "PHONE",
239
+ wallet: "WALLET",
240
+ money: "AMOUNT",
241
+ };
242
+
243
+ /** Puts a vault's originals back in place of its placeholders (only ever for text staying on this computer). */
244
+ export function restore(text, vault) {
245
+ let out = String(text);
246
+ for (const [placeholder, original] of vault)
247
+ out = out.split(placeholder).join(original);
248
+ return out;
249
+ }
250
+
251
+ /** restore over every string in a JSON-like value. */
252
+ export function restoreDeep(value, vault) {
253
+ if (typeof value === "string") return restore(value, 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
+ );
263
+ return value;
264
+ }
265
+
266
+ /** Applies scrub to every string in a JSON-like value. */
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
+ }
287
+ return value;
288
+ }