tryharness 0.7.23 → 0.7.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/opencode-plugin.js +48 -11
- package/assets/pi-extension.ts +105 -26
- package/cli.mjs +1238 -322
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +383 -83
- package/lib/cards.mjs +174 -40
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +409 -71
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +274 -57
- package/lib/daemon.mjs +5248 -1024
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +54 -13
- package/lib/environment.mjs +103 -21
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +169 -35
- package/lib/folders.mjs +179 -36
- package/lib/frames.mjs +214 -40
- package/lib/inference.mjs +463 -126
- package/lib/learn.mjs +78 -16
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +424 -83
- package/lib/mcp-stdio.mjs +868 -151
- package/lib/media.mjs +138 -28
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +184 -33
- package/lib/ocr.mjs +223 -60
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +51 -13
- package/lib/phones.mjs +382 -60
- package/lib/places.mjs +555 -107
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +51 -11
- package/lib/redact.mjs +512 -116
- package/lib/runner.mjs +345 -55
- package/lib/runtime-install.mjs +55 -14
- package/lib/screens.mjs +154 -38
- package/lib/sealed.mjs +157 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +170 -34
- package/lib/snippets.mjs +94 -18
- package/lib/speech.mjs +688 -175
- package/lib/store.mjs +1654 -307
- package/lib/swarm.mjs +596 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +285 -61
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +247 -48
- package/lib/worktrees.mjs +186 -38
- 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 {
|
|
9
|
+
try {
|
|
10
|
+
fs.chmodSync(dir, 0o700);
|
|
11
|
+
} catch {}
|
|
7
12
|
}
|
|
8
13
|
|
|
9
14
|
export function readJson(file, fallback = {}) {
|
|
10
|
-
try {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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",
|
|
50
|
-
|
|
51
|
-
|
|
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",
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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) =>
|
|
37
|
-
|
|
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) =>
|
|
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) => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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))
|
|
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) =>
|
|
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())
|
|
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)
|
|
69
|
-
|
|
70
|
-
|
|
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 {
|
|
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) => {
|
|
85
|
-
|
|
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 = (
|
|
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) => {
|
|
92
|
-
|
|
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)
|
|
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 (
|
|
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
|
|
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(
|
|
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) {
|
|
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 = {
|
|
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)
|
|
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))
|
|
144
|
-
|
|
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(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
}
|