@alumnium/test 0.0.0-stage → 0.24.0
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/LICENSE.md +21 -0
- package/README.md +2 -2
- package/package.json +37 -4
- package/src/FileTestCache.d.ts +26 -0
- package/src/Recorder.d.ts +85 -0
- package/src/Recording.d.ts +288 -0
- package/src/RecordingLeakError.d.ts +4 -0
- package/src/TestCache.d.ts +38 -0
- package/src/index.cjs +961 -0
- package/src/index.cjs.map +20 -0
- package/src/index.d.ts +6 -0
- package/src/index.js +880 -0
- package/src/index.js.map +20 -0
- package/src/masker/HostVarMasker.d.ts +27 -0
- package/src/masker/Masker.d.ts +163 -0
- package/src/masker/ResponsePaths.d.ts +35 -0
- package/src/masker/SecretMasker.d.ts +58 -0
- package/src/masker/SubstringMasker.d.ts +41 -0
- package/src/masker/ToolCallMasker.d.ts +177 -0
- package/src/masker/maskerUtils.d.ts +83 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
// test/src/Recording.ts
|
|
2
|
+
import { xxh64Str } from "@js-fns/xxhash/str";
|
|
3
|
+
import z from "zod";
|
|
4
|
+
// test/src/FileTestCache.ts
|
|
5
|
+
import {
|
|
6
|
+
Env,
|
|
7
|
+
FileStore,
|
|
8
|
+
GlobalFileStorePaths,
|
|
9
|
+
safePathJoin,
|
|
10
|
+
Telemetry,
|
|
11
|
+
} from "@alumnium/core/internal";
|
|
12
|
+
|
|
13
|
+
class Recording {
|
|
14
|
+
static Id = z.string().brand("Recording.Id");
|
|
15
|
+
static Input = z.record(z.string(), z.unknown());
|
|
16
|
+
static Content = z.union([
|
|
17
|
+
z.string(),
|
|
18
|
+
z.array(z.looseObject({ type: z.string() })),
|
|
19
|
+
]);
|
|
20
|
+
static ToolCall = z.object({
|
|
21
|
+
id: z.string(),
|
|
22
|
+
server: z.string().optional(),
|
|
23
|
+
tool: z.string(),
|
|
24
|
+
input: this.Input,
|
|
25
|
+
});
|
|
26
|
+
static ToolResult = z.object({
|
|
27
|
+
content: this.Content,
|
|
28
|
+
isError: z.boolean(),
|
|
29
|
+
});
|
|
30
|
+
static ToolStep = z.object({
|
|
31
|
+
kind: z.literal("tool-call"),
|
|
32
|
+
call: this.ToolCall,
|
|
33
|
+
result: this.ToolResult,
|
|
34
|
+
});
|
|
35
|
+
static NarrationStep = z.object({
|
|
36
|
+
kind: z.literal("narration"),
|
|
37
|
+
narration: z.enum(["thinking", "assistant"]),
|
|
38
|
+
text: z.string(),
|
|
39
|
+
});
|
|
40
|
+
static Step = z.union([this.ToolStep, this.NarrationStep]);
|
|
41
|
+
static Verdict = z.object({
|
|
42
|
+
status: z.enum(["success", "failure"]),
|
|
43
|
+
details: z.string(),
|
|
44
|
+
});
|
|
45
|
+
static DEFAULT_VERSION = 1;
|
|
46
|
+
static Schema = z.object({
|
|
47
|
+
id: this.Id,
|
|
48
|
+
version: z.number().int().positive(),
|
|
49
|
+
text: z.string(),
|
|
50
|
+
agent: z.string(),
|
|
51
|
+
alumnium: z.object({ version: z.string() }),
|
|
52
|
+
hostVars: z.array(z.string()),
|
|
53
|
+
steps: z.array(this.Step),
|
|
54
|
+
verdict: this.Verdict,
|
|
55
|
+
});
|
|
56
|
+
static textToId(text) {
|
|
57
|
+
return xxh64Str(text.trim());
|
|
58
|
+
}
|
|
59
|
+
static toolKey(call) {
|
|
60
|
+
return call.server ? `${call.server}/${call.tool}` : call.tool;
|
|
61
|
+
}
|
|
62
|
+
static executableStepsCount(recording) {
|
|
63
|
+
return recording.steps.filter((step) => step.kind !== "narration").length;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// test/src/FileTestCache.ts
|
|
68
|
+
var { logger } = Telemetry.get("packages/typescript/test/src/FileTestCache.ts");
|
|
69
|
+
|
|
70
|
+
class FileTestCache {
|
|
71
|
+
#store;
|
|
72
|
+
#staged = new Map();
|
|
73
|
+
#lookups = { hits: 0, misses: 0 };
|
|
74
|
+
constructor(props = {}) {
|
|
75
|
+
this.#store = new FileStore(props.dir ?? FileTestCache.defaultDir());
|
|
76
|
+
}
|
|
77
|
+
static defaultDir() {
|
|
78
|
+
return safePathJoin(
|
|
79
|
+
Env.ALUMNIUM_CACHE_PATH ?? GlobalFileStorePaths.globalSubDir("cache"),
|
|
80
|
+
"tests",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
get dir() {
|
|
84
|
+
return this.#store.dir;
|
|
85
|
+
}
|
|
86
|
+
get lookups() {
|
|
87
|
+
return { ...this.#lookups };
|
|
88
|
+
}
|
|
89
|
+
async lookup(text, options = {}) {
|
|
90
|
+
const { version = Recording.DEFAULT_VERSION } = options;
|
|
91
|
+
const id = Recording.textToId(text);
|
|
92
|
+
const found = this.#staged.get(id) ?? (await this.#read(id));
|
|
93
|
+
if (found?.version === version) {
|
|
94
|
+
logger.debug(`Test cache hit: ${id}`);
|
|
95
|
+
this.#lookups.hits++;
|
|
96
|
+
return structuredClone(found);
|
|
97
|
+
}
|
|
98
|
+
logger.debug(
|
|
99
|
+
found
|
|
100
|
+
? `Test cache miss: ${id} is recorded in version ${found.version}, not ${version}`
|
|
101
|
+
: `Test cache miss: ${id}`,
|
|
102
|
+
);
|
|
103
|
+
this.#lookups.misses++;
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
async update(recording) {
|
|
107
|
+
this.#staged.set(recording.id, structuredClone(recording));
|
|
108
|
+
}
|
|
109
|
+
async save() {
|
|
110
|
+
const staged = [...this.#staged];
|
|
111
|
+
if (!staged.length) return;
|
|
112
|
+
logger.debug(`Saving ${staged.length} test recordings...`);
|
|
113
|
+
await Promise.all(
|
|
114
|
+
staged.map(([id, recording]) =>
|
|
115
|
+
this.#store.writeJson(fileName(id), recording),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
for (const [id, recording] of staged)
|
|
119
|
+
if (this.#staged.get(id) === recording) this.#staged.delete(id);
|
|
120
|
+
}
|
|
121
|
+
async discard() {
|
|
122
|
+
this.#staged.clear();
|
|
123
|
+
}
|
|
124
|
+
async #read(id) {
|
|
125
|
+
try {
|
|
126
|
+
return await this.#store.readJson(fileName(id), Recording.Schema);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
logger.warn(`Failed to read test recording ${id}: {error}`, { error });
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function fileName(id) {
|
|
134
|
+
return `${id}.json`;
|
|
135
|
+
}
|
|
136
|
+
// test/src/masker/HostVarMasker.ts
|
|
137
|
+
import { Telemetry as Telemetry2 } from "@alumnium/core/internal";
|
|
138
|
+
|
|
139
|
+
// test/src/masker/maskerUtils.ts
|
|
140
|
+
function eachStringLeaf(container, mapLeaf, path = "") {
|
|
141
|
+
if (Array.isArray(container)) {
|
|
142
|
+
container.forEach((item, index) => {
|
|
143
|
+
const itemPath = `${path}[${index}]`;
|
|
144
|
+
if (typeof item !== "string")
|
|
145
|
+
return eachStringLeaf(item, mapLeaf, itemPath);
|
|
146
|
+
const mapped = mapLeaf(item, itemPath);
|
|
147
|
+
if (mapped !== undefined) container[index] = mapped;
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (typeof container !== "object" || container === null) return;
|
|
152
|
+
for (const [key, value] of Object.entries(container)) {
|
|
153
|
+
const valuePath = path ? `${path}.${key}` : key;
|
|
154
|
+
if (typeof value !== "string") {
|
|
155
|
+
eachStringLeaf(value, mapLeaf, valuePath);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const mapped = mapLeaf(value, valuePath);
|
|
159
|
+
if (mapped !== undefined) Object.assign(container, { [key]: mapped });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function findMasks(value, pattern) {
|
|
163
|
+
const masks = [];
|
|
164
|
+
eachStringLeaf({ value }, (leaf) => {
|
|
165
|
+
masks.push(...(leaf.match(pattern) ?? []));
|
|
166
|
+
return;
|
|
167
|
+
});
|
|
168
|
+
return masks;
|
|
169
|
+
}
|
|
170
|
+
function parseInput(value) {
|
|
171
|
+
const parseResult = Recording.Input.safeParse(value);
|
|
172
|
+
if (!parseResult.success)
|
|
173
|
+
throw new TypeError("Invalid tool input, expected an object");
|
|
174
|
+
return structuredClone(parseResult.data);
|
|
175
|
+
}
|
|
176
|
+
function parseJson(text) {
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(text);
|
|
179
|
+
} catch {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function parseJsonStructure(text) {
|
|
184
|
+
const parsed = parseJson(text);
|
|
185
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
186
|
+
return parsed;
|
|
187
|
+
}
|
|
188
|
+
function contentTexts(content) {
|
|
189
|
+
if (typeof content === "string") return [content];
|
|
190
|
+
return content.flatMap((block) =>
|
|
191
|
+
block.type === "text" && typeof block["text"] === "string"
|
|
192
|
+
? [block["text"]]
|
|
193
|
+
: [],
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
function hasToolKey(keys, toolKey) {
|
|
197
|
+
if (keys.has(toolKey)) return true;
|
|
198
|
+
const separatorIndex = toolKey.lastIndexOf("/");
|
|
199
|
+
return (
|
|
200
|
+
separatorIndex >= 0 && keys.has(`${toolKey.slice(0, separatorIndex)}/*`)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
function maskSegment(segment) {
|
|
204
|
+
return segment.replace(/[^A-Za-z0-9_]+/g, "_");
|
|
205
|
+
}
|
|
206
|
+
var ANY_MASK_PATTERN = /<(?:tool-call|secret|host-var)\.[\w.]+(?::json)?>/g;
|
|
207
|
+
function mapOutsideMasks(value, map) {
|
|
208
|
+
let result = "";
|
|
209
|
+
let index = 0;
|
|
210
|
+
for (const match of value.matchAll(ANY_MASK_PATTERN)) {
|
|
211
|
+
result += map(value.slice(index, match.index)) + match[0];
|
|
212
|
+
index = match.index + match[0].length;
|
|
213
|
+
}
|
|
214
|
+
return result + map(value.slice(index));
|
|
215
|
+
}
|
|
216
|
+
function escapeRegExp(value) {
|
|
217
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// test/src/masker/SubstringMasker.ts
|
|
221
|
+
class SubstringMasker {
|
|
222
|
+
static JSON_SUFFIX = ":json";
|
|
223
|
+
#pattern;
|
|
224
|
+
#valueByMask = new Map();
|
|
225
|
+
#maskByValue = new Map();
|
|
226
|
+
constructor(pattern) {
|
|
227
|
+
this.#pattern = pattern;
|
|
228
|
+
}
|
|
229
|
+
get size() {
|
|
230
|
+
return this.#valueByMask.size;
|
|
231
|
+
}
|
|
232
|
+
register(mask, value) {
|
|
233
|
+
this.#valueByMask.set(mask, value);
|
|
234
|
+
if (!this.#maskByValue.has(value)) this.#maskByValue.set(value, mask);
|
|
235
|
+
}
|
|
236
|
+
mask(value) {
|
|
237
|
+
if (!this.#maskByValue.size) return value;
|
|
238
|
+
if (typeof value === "string") return this.maskString(value);
|
|
239
|
+
eachStringLeaf(value, (leaf) => {
|
|
240
|
+
const masked = this.maskString(leaf);
|
|
241
|
+
return masked === leaf ? undefined : masked;
|
|
242
|
+
});
|
|
243
|
+
return value;
|
|
244
|
+
}
|
|
245
|
+
maskString(value) {
|
|
246
|
+
if (!this.#maskByValue.size) return value;
|
|
247
|
+
const maskByText = new Map();
|
|
248
|
+
for (const [secret, mask] of this.#maskByValue) {
|
|
249
|
+
const escaped = this.#jsonEscaped(secret);
|
|
250
|
+
maskByText.set(secret, mask);
|
|
251
|
+
if (escaped !== secret && !maskByText.has(escaped))
|
|
252
|
+
maskByText.set(escaped, this.#jsonMask(mask));
|
|
253
|
+
}
|
|
254
|
+
const pattern = new RegExp(
|
|
255
|
+
[...maskByText.keys()]
|
|
256
|
+
.sort((a, b) => b.length - a.length)
|
|
257
|
+
.map(escapeRegExp)
|
|
258
|
+
.join("|"),
|
|
259
|
+
"g",
|
|
260
|
+
);
|
|
261
|
+
return mapOutsideMasks(value, (text) =>
|
|
262
|
+
text.replace(pattern, (match) => maskByText.get(match) ?? match),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
unmask(value) {
|
|
266
|
+
if (!this.#valueByMask.size) return value;
|
|
267
|
+
eachStringLeaf(value, (leaf) => {
|
|
268
|
+
const unmasked = leaf.replace(this.#pattern, (mask) => {
|
|
269
|
+
const escaped = mask.endsWith(`${SubstringMasker.JSON_SUFFIX}>`);
|
|
270
|
+
const value = this.#valueByMask.get(
|
|
271
|
+
escaped
|
|
272
|
+
? `${mask.slice(0, -SubstringMasker.JSON_SUFFIX.length - 1)}>`
|
|
273
|
+
: mask,
|
|
274
|
+
);
|
|
275
|
+
if (value === undefined) return mask;
|
|
276
|
+
return escaped ? this.#jsonEscaped(value) : value;
|
|
277
|
+
});
|
|
278
|
+
return unmasked === leaf ? undefined : unmasked;
|
|
279
|
+
});
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
findLeaks(value) {
|
|
283
|
+
const leaks = new Set();
|
|
284
|
+
eachStringLeaf({ value }, (leaf) => {
|
|
285
|
+
mapOutsideMasks(leaf, (text) => {
|
|
286
|
+
for (const [secret, mask] of this.#maskByValue) {
|
|
287
|
+
if (text.includes(secret) || text.includes(this.#jsonEscaped(secret)))
|
|
288
|
+
leaks.add(mask);
|
|
289
|
+
}
|
|
290
|
+
return text;
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
});
|
|
294
|
+
return [...leaks];
|
|
295
|
+
}
|
|
296
|
+
#jsonEscaped(value) {
|
|
297
|
+
return JSON.stringify(value).slice(1, -1);
|
|
298
|
+
}
|
|
299
|
+
#jsonMask(mask) {
|
|
300
|
+
return `${mask.slice(0, -1)}${SubstringMasker.JSON_SUFFIX}>`;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// test/src/masker/HostVarMasker.ts
|
|
305
|
+
var { logger: logger2 } = Telemetry2.get(
|
|
306
|
+
"packages/typescript/test/src/masker/HostVarMasker.ts",
|
|
307
|
+
);
|
|
308
|
+
var HOST_MASK_PREFIX = "<host-var.";
|
|
309
|
+
var HOST_VAR_NAME_PATTERN = /^\w+$/;
|
|
310
|
+
var HOST_MASK_PATTERN = /<host-var\.\w+(?::json)?>/g;
|
|
311
|
+
var HOST_MASK_NAME_PATTERN = /<host-var\.(\w+)(?::json)?>/g;
|
|
312
|
+
|
|
313
|
+
class HostVarMasker extends SubstringMasker {
|
|
314
|
+
constructor(hostVars = {}) {
|
|
315
|
+
super(HOST_MASK_PATTERN);
|
|
316
|
+
for (const [name, value] of Object.entries(hostVars)) {
|
|
317
|
+
if (!HOST_VAR_NAME_PATTERN.test(name))
|
|
318
|
+
throw new RangeError(
|
|
319
|
+
`Host variable name '${name}' must only contain word characters`,
|
|
320
|
+
);
|
|
321
|
+
if (!value) {
|
|
322
|
+
logger2.debug(`Host variable ${name} is empty, not masking it`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
this.register(`${HOST_MASK_PREFIX}${name}>`, value);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
static findMasks(value) {
|
|
329
|
+
return findMasks(value, HOST_MASK_PATTERN);
|
|
330
|
+
}
|
|
331
|
+
static findNames(value) {
|
|
332
|
+
const names = new Set();
|
|
333
|
+
eachStringLeaf({ value }, (leaf) => {
|
|
334
|
+
for (const [, name] of leaf.matchAll(HOST_MASK_NAME_PATTERN))
|
|
335
|
+
if (name) names.add(name);
|
|
336
|
+
return;
|
|
337
|
+
});
|
|
338
|
+
return [...names];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// test/src/masker/ToolCallMasker.ts
|
|
343
|
+
import { Telemetry as Telemetry4 } from "@alumnium/core/internal";
|
|
344
|
+
|
|
345
|
+
// test/src/masker/ResponsePaths.ts
|
|
346
|
+
var PATH_SEPARATOR = ".";
|
|
347
|
+
var PATH_WILDCARD = "*";
|
|
348
|
+
var PATH_DEEP_WILDCARD = "**";
|
|
349
|
+
var TOOL_KEY_SEPARATOR = "/";
|
|
350
|
+
|
|
351
|
+
class ResponsePaths {
|
|
352
|
+
#patterns;
|
|
353
|
+
constructor(paths = {}) {
|
|
354
|
+
this.#patterns = Object.fromEntries(
|
|
355
|
+
Object.entries(paths).map(([key, keyPaths]) => [
|
|
356
|
+
key,
|
|
357
|
+
keyPaths.map((path) => path.split(PATH_SEPARATOR)),
|
|
358
|
+
]),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
of(toolKey) {
|
|
362
|
+
if (!toolKey) return;
|
|
363
|
+
const patterns = this.#patterns[toolKey];
|
|
364
|
+
if (patterns) return patterns;
|
|
365
|
+
const separatorIndex = toolKey.lastIndexOf(TOOL_KEY_SEPARATOR);
|
|
366
|
+
if (separatorIndex < 0) return;
|
|
367
|
+
const server = toolKey.slice(0, separatorIndex);
|
|
368
|
+
return this.#patterns[`${server}${TOOL_KEY_SEPARATOR}${PATH_WILDCARD}`];
|
|
369
|
+
}
|
|
370
|
+
has(toolKey) {
|
|
371
|
+
return !!this.of(toolKey)?.length;
|
|
372
|
+
}
|
|
373
|
+
covers(toolKey, path) {
|
|
374
|
+
return (this.of(toolKey) ?? []).some((pattern) =>
|
|
375
|
+
this.#matchesStart(pattern, path),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
#matchesStart(pattern, path, patternIndex = 0, pathIndex = 0) {
|
|
379
|
+
if (patternIndex === pattern.length) return true;
|
|
380
|
+
const segment = pattern[patternIndex];
|
|
381
|
+
if (segment === PATH_DEEP_WILDCARD) {
|
|
382
|
+
for (let index = pathIndex; index <= path.length; index++)
|
|
383
|
+
if (this.#matchesStart(pattern, path, patternIndex + 1, index))
|
|
384
|
+
return true;
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
if (pathIndex === path.length) return false;
|
|
388
|
+
if (segment !== PATH_WILDCARD && segment !== path[pathIndex]) return false;
|
|
389
|
+
return this.#matchesStart(pattern, path, patternIndex + 1, pathIndex + 1);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// test/src/masker/SecretMasker.ts
|
|
394
|
+
import { Telemetry as Telemetry3 } from "@alumnium/core/internal";
|
|
395
|
+
var { logger: logger3 } = Telemetry3.get(
|
|
396
|
+
"packages/typescript/test/src/masker/SecretMasker.ts",
|
|
397
|
+
);
|
|
398
|
+
var SECRET_MASK_PREFIX = "<secret.";
|
|
399
|
+
var SECRET_MASK_PATTERN = /<secret\.\w+(?:\.\w+)+(?::json)?>/g;
|
|
400
|
+
|
|
401
|
+
class SecretMasker extends SubstringMasker {
|
|
402
|
+
static PREFIX = SECRET_MASK_PREFIX;
|
|
403
|
+
#paths;
|
|
404
|
+
#unreadOutputs = new Set();
|
|
405
|
+
static findMasks(value) {
|
|
406
|
+
return findMasks(value, SECRET_MASK_PATTERN);
|
|
407
|
+
}
|
|
408
|
+
constructor(maskResponsePaths = {}) {
|
|
409
|
+
super(SECRET_MASK_PATTERN);
|
|
410
|
+
this.#paths = new ResponsePaths(maskResponsePaths);
|
|
411
|
+
}
|
|
412
|
+
hasPaths(toolKey) {
|
|
413
|
+
return this.#paths.has(toolKey);
|
|
414
|
+
}
|
|
415
|
+
isSecret(toolKey, path) {
|
|
416
|
+
return this.#paths.covers(toolKey, path);
|
|
417
|
+
}
|
|
418
|
+
registerSecret(mask, value) {
|
|
419
|
+
this.register(mask, value);
|
|
420
|
+
}
|
|
421
|
+
markUnread(callId) {
|
|
422
|
+
logger3.warn(
|
|
423
|
+
`Tool call ${callId} has response paths to mask, but its output is not JSON`,
|
|
424
|
+
);
|
|
425
|
+
this.#unreadOutputs.add(`${SECRET_MASK_PREFIX}${maskSegment(callId)}>`);
|
|
426
|
+
}
|
|
427
|
+
findLeaks(value) {
|
|
428
|
+
return [...this.#unreadOutputs, ...super.findLeaks(value)];
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// test/src/masker/ToolCallMasker.ts
|
|
433
|
+
var { logger: logger4 } = Telemetry4.get(
|
|
434
|
+
"packages/typescript/test/src/masker/ToolCallMasker.ts",
|
|
435
|
+
);
|
|
436
|
+
var TOOL_CALL_MASK_PREFIX = "<tool-call.";
|
|
437
|
+
var TOOL_CALL_MASK_PATTERN = /^<tool-call\.\w+(?:\.\w+)+>$/;
|
|
438
|
+
var QUOTED_DELIMITER_SOURCE = String.raw`\\*['"]`;
|
|
439
|
+
var QUOTED_RUN_PATTERN = new RegExp(
|
|
440
|
+
String.raw`(\\*)(?:'([^'\\]*)\1'|"([^"\\]*)\1")`,
|
|
441
|
+
"g",
|
|
442
|
+
);
|
|
443
|
+
var QUOTED_TOOL_CALL_MASK_PATTERN = new RegExp(
|
|
444
|
+
`(${QUOTED_DELIMITER_SOURCE})(<tool-call\\.\\w+(?:\\.\\w+)+>)\\1`,
|
|
445
|
+
"g",
|
|
446
|
+
);
|
|
447
|
+
var UNMASKED_INPUT_PATHS = new Set(["description"]);
|
|
448
|
+
var DEFAULT_VERBATIM_TOOLS = ["TodoWrite"];
|
|
449
|
+
var PATH_SEPARATOR2 = ".";
|
|
450
|
+
|
|
451
|
+
class ToolCallMasker {
|
|
452
|
+
#verbatimTools;
|
|
453
|
+
#wholeValueOnlyTools;
|
|
454
|
+
#registerPaths;
|
|
455
|
+
#secrets;
|
|
456
|
+
#valueByMask = new Map();
|
|
457
|
+
#maskByValue = new Map();
|
|
458
|
+
constructor(props = {}) {
|
|
459
|
+
this.#verbatimTools = new Set(
|
|
460
|
+
props.verbatimTools ?? DEFAULT_VERBATIM_TOOLS,
|
|
461
|
+
);
|
|
462
|
+
this.#wholeValueOnlyTools = new Set(props.wholeValueOnlyTools);
|
|
463
|
+
this.#registerPaths = new ResponsePaths(props.registerResponsePaths);
|
|
464
|
+
this.#secrets = new SecretMasker(props.maskResponsePaths);
|
|
465
|
+
}
|
|
466
|
+
masksInputOf(toolKey) {
|
|
467
|
+
return !hasToolKey(this.#verbatimTools, toolKey);
|
|
468
|
+
}
|
|
469
|
+
scopeOf(toolKey) {
|
|
470
|
+
return hasToolKey(this.#wholeValueOnlyTools, toolKey)
|
|
471
|
+
? "whole"
|
|
472
|
+
: "embedded";
|
|
473
|
+
}
|
|
474
|
+
maskInput(toolKey, input) {
|
|
475
|
+
if (!this.masksInputOf(toolKey)) return this.maskSecrets(input);
|
|
476
|
+
return this.mask(input, this.scopeOf(toolKey));
|
|
477
|
+
}
|
|
478
|
+
unmaskInput(toolKey, input) {
|
|
479
|
+
return this.unmask(input, this.scopeOf(toolKey));
|
|
480
|
+
}
|
|
481
|
+
static findUnresolved(input) {
|
|
482
|
+
const masks = [];
|
|
483
|
+
eachStringLeaf(input, (value) => {
|
|
484
|
+
if (TOOL_CALL_MASK_PATTERN.test(value)) masks.push(value);
|
|
485
|
+
return;
|
|
486
|
+
});
|
|
487
|
+
return [...masks, ...SecretMasker.findMasks(input)];
|
|
488
|
+
}
|
|
489
|
+
findLeaks(value) {
|
|
490
|
+
return this.#secrets.findLeaks(value);
|
|
491
|
+
}
|
|
492
|
+
registerContent(callId, content, toolKey, options = {}) {
|
|
493
|
+
if (this.#registersNothing(toolKey, options)) return [];
|
|
494
|
+
const text = contentTexts(content)
|
|
495
|
+
.join(`
|
|
496
|
+
`)
|
|
497
|
+
.trim();
|
|
498
|
+
if (!text) {
|
|
499
|
+
logger4.debug(`Tool call ${callId} produced no text output`);
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
const json = parseJsonStructure(text);
|
|
503
|
+
if (!json) {
|
|
504
|
+
logger4.info(
|
|
505
|
+
`Tool call ${callId} output is not a JSON object or array, ignoring it for masking. ` +
|
|
506
|
+
"The values it produced will be replayed as recorded.",
|
|
507
|
+
);
|
|
508
|
+
logger4.debug(`-> Output: ${text}`);
|
|
509
|
+
if (this.#secrets.hasPaths(toolKey)) this.#secrets.markUnread(callId);
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
return this.registerOutput(callId, json, toolKey, options);
|
|
513
|
+
}
|
|
514
|
+
registerOutput(callId, json, toolKey, options = {}) {
|
|
515
|
+
const registered = [];
|
|
516
|
+
this.#collectJsonLeaves(json).forEach(({ path, value }) => {
|
|
517
|
+
const secret = this.#secrets.isSecret(toolKey, path);
|
|
518
|
+
if (!secret && (options.secretsOnly || !this.#registers(toolKey, path)))
|
|
519
|
+
return;
|
|
520
|
+
const prefix = secret ? SecretMasker.PREFIX : TOOL_CALL_MASK_PREFIX;
|
|
521
|
+
let mask = this.#leafMask(prefix, callId, path);
|
|
522
|
+
if (this.#valueByMask.has(mask)) {
|
|
523
|
+
if (!secret) {
|
|
524
|
+
logger4.warn(
|
|
525
|
+
`Tool call mask ${mask} is already registered, skipping`,
|
|
526
|
+
);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
mask = this.#uniqueMask(mask);
|
|
530
|
+
}
|
|
531
|
+
this.#valueByMask.set(mask, value);
|
|
532
|
+
if (!this.#maskByValue.has(value)) this.#maskByValue.set(value, mask);
|
|
533
|
+
if (secret) this.#secrets.registerSecret(mask, value);
|
|
534
|
+
logger4.debug(`Registered tool call value ${mask}`);
|
|
535
|
+
registered.push({ mask, value });
|
|
536
|
+
});
|
|
537
|
+
return registered;
|
|
538
|
+
}
|
|
539
|
+
#registers(toolKey, path) {
|
|
540
|
+
return (
|
|
541
|
+
!this.#registerPaths.of(toolKey) ||
|
|
542
|
+
this.#registerPaths.covers(toolKey, path)
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
#registersNothing(toolKey, options) {
|
|
546
|
+
if (this.#secrets.hasPaths(toolKey)) return false;
|
|
547
|
+
return (
|
|
548
|
+
!!options.secretsOnly || this.#registerPaths.of(toolKey)?.length === 0
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
#collectJsonLeaves(json) {
|
|
552
|
+
const leaves = [];
|
|
553
|
+
const walk = (value, path) => {
|
|
554
|
+
if (Array.isArray(value)) {
|
|
555
|
+
value.forEach((item, index) => walk(item, [...path, String(index)]));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (typeof value === "object" && value !== null) {
|
|
559
|
+
for (const [key, nested] of Object.entries(value))
|
|
560
|
+
walk(nested, [...path, key]);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (typeof value !== "string" && typeof value !== "number") return;
|
|
564
|
+
const leafValue = String(value);
|
|
565
|
+
if (!leafValue.trim()) return;
|
|
566
|
+
leaves.push({ path, value: leafValue });
|
|
567
|
+
};
|
|
568
|
+
walk(json, []);
|
|
569
|
+
return leaves.sort((a, b) => comparePaths(a.path, b.path));
|
|
570
|
+
}
|
|
571
|
+
#uniqueMask(mask) {
|
|
572
|
+
const base = mask.slice(0, -1);
|
|
573
|
+
let index = 2;
|
|
574
|
+
while (this.#valueByMask.has(`${base}_${index}>`)) index++;
|
|
575
|
+
return `${base}_${index}>`;
|
|
576
|
+
}
|
|
577
|
+
#leafMask(prefix, callId, path) {
|
|
578
|
+
return `${prefix}${[callId, ...path].map(maskSegment).join(PATH_SEPARATOR2)}>`;
|
|
579
|
+
}
|
|
580
|
+
mask(input, scope) {
|
|
581
|
+
if (!this.#maskByValue.size) return input;
|
|
582
|
+
eachStringLeaf(input, (value, path) => {
|
|
583
|
+
if (scope === "embedded" && UNMASKED_INPUT_PATHS.has(path)) return;
|
|
584
|
+
const mask = this.#maskByValue.get(value);
|
|
585
|
+
if (mask) {
|
|
586
|
+
logger4.debug(
|
|
587
|
+
`Masked tool call value in '${path}': '${value}' -> '${mask}'`,
|
|
588
|
+
);
|
|
589
|
+
return mask;
|
|
590
|
+
}
|
|
591
|
+
if (scope !== "embedded") return;
|
|
592
|
+
return this.#maskQuoted(value, path);
|
|
593
|
+
});
|
|
594
|
+
return this.maskSecrets(input);
|
|
595
|
+
}
|
|
596
|
+
maskSecrets(value) {
|
|
597
|
+
return this.#secrets.mask(value);
|
|
598
|
+
}
|
|
599
|
+
#maskQuoted(value, path) {
|
|
600
|
+
const result = this.#maskQuotedRuns(value, path);
|
|
601
|
+
return result === value ? undefined : result;
|
|
602
|
+
}
|
|
603
|
+
#maskQuotedRuns(value, path) {
|
|
604
|
+
return value.replace(
|
|
605
|
+
QUOTED_RUN_PATTERN,
|
|
606
|
+
(match, escapes, single, double) => {
|
|
607
|
+
const delimiter = `${escapes}${single === undefined ? '"' : "'"}`;
|
|
608
|
+
const token = single ?? double ?? "";
|
|
609
|
+
if (token.search(QUOTED_RUN_PATTERN) >= 0)
|
|
610
|
+
return `${delimiter}${this.#maskQuotedRuns(token, path)}${delimiter}`;
|
|
611
|
+
const mask = this.#maskByValue.get(token);
|
|
612
|
+
if (!mask) return match;
|
|
613
|
+
logger4.debug(
|
|
614
|
+
`Masked quoted tool call value in '${path}': '${token}' -> '${mask}'`,
|
|
615
|
+
);
|
|
616
|
+
return `${delimiter}${mask}${delimiter}`;
|
|
617
|
+
},
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
unmask(input, scope) {
|
|
621
|
+
if (!this.#valueByMask.size) return input;
|
|
622
|
+
eachStringLeaf(input, (mask, path) => {
|
|
623
|
+
const value = this.#valueByMask.get(mask);
|
|
624
|
+
if (value !== undefined) {
|
|
625
|
+
logger4.debug(
|
|
626
|
+
`Unmasked tool call value in '${path}': '${mask}' -> '${value}'`,
|
|
627
|
+
);
|
|
628
|
+
return value;
|
|
629
|
+
}
|
|
630
|
+
if (scope === "embedded") return this.#unmaskQuoted(mask, path);
|
|
631
|
+
});
|
|
632
|
+
return this.#secrets.unmask(input);
|
|
633
|
+
}
|
|
634
|
+
#unmaskQuoted(value, path) {
|
|
635
|
+
let unmasked = false;
|
|
636
|
+
const result = value.replace(
|
|
637
|
+
QUOTED_TOOL_CALL_MASK_PATTERN,
|
|
638
|
+
(match, delimiter, mask) => {
|
|
639
|
+
const fresh = this.#valueByMask.get(mask);
|
|
640
|
+
if (fresh === undefined) return match;
|
|
641
|
+
if (this.#breaksQuoting(fresh, delimiter)) {
|
|
642
|
+
logger4.warn(
|
|
643
|
+
`Fresh tool call value for ${mask} cannot be quoted with ${delimiter} in '${path}', leaving the mask in place`,
|
|
644
|
+
);
|
|
645
|
+
return match;
|
|
646
|
+
}
|
|
647
|
+
logger4.debug(
|
|
648
|
+
`Unmasked quoted tool call value in '${path}': '${mask}' -> '${fresh}'`,
|
|
649
|
+
);
|
|
650
|
+
unmasked = true;
|
|
651
|
+
return `${delimiter}${fresh}${delimiter}`;
|
|
652
|
+
},
|
|
653
|
+
);
|
|
654
|
+
return unmasked ? result : undefined;
|
|
655
|
+
}
|
|
656
|
+
#breaksQuoting(value, delimiter) {
|
|
657
|
+
const quote = delimiter.slice(-1);
|
|
658
|
+
if (value.includes(quote)) return true;
|
|
659
|
+
const interprets = quote === '"' || delimiter.length > 1;
|
|
660
|
+
return interprets && /[\\$`]/.test(value);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
function comparePaths(a, b) {
|
|
664
|
+
for (let index = 0; index < Math.min(a.length, b.length); index++) {
|
|
665
|
+
const [left = "", right = ""] = [a[index], b[index]];
|
|
666
|
+
if (left !== right) return left < right ? -1 : 1;
|
|
667
|
+
}
|
|
668
|
+
return a.length - b.length;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// test/src/masker/Masker.ts
|
|
672
|
+
class Masker {
|
|
673
|
+
#toolCalls;
|
|
674
|
+
#hostVars;
|
|
675
|
+
constructor(props = {}) {
|
|
676
|
+
const { hostVars, ...toolCallProps } = props;
|
|
677
|
+
this.#toolCalls = new ToolCallMasker(toolCallProps);
|
|
678
|
+
this.#hostVars = new HostVarMasker(hostVars);
|
|
679
|
+
}
|
|
680
|
+
masksToolCallValuesOf(toolKey) {
|
|
681
|
+
return this.#toolCalls.masksInputOf(toolKey);
|
|
682
|
+
}
|
|
683
|
+
registerOutput(callId, content, toolKey, options) {
|
|
684
|
+
this.#toolCalls.registerContent(callId, content, toolKey, options);
|
|
685
|
+
}
|
|
686
|
+
static findUnresolvedMasks(input) {
|
|
687
|
+
return [
|
|
688
|
+
...new Set([
|
|
689
|
+
...ToolCallMasker.findUnresolved(input),
|
|
690
|
+
...HostVarMasker.findMasks(input),
|
|
691
|
+
]),
|
|
692
|
+
];
|
|
693
|
+
}
|
|
694
|
+
static findHostVarNames(value) {
|
|
695
|
+
return HostVarMasker.findNames(value);
|
|
696
|
+
}
|
|
697
|
+
findLeaks(value) {
|
|
698
|
+
return [
|
|
699
|
+
...this.#hostVars.findLeaks(value),
|
|
700
|
+
...this.#toolCalls.findLeaks(value),
|
|
701
|
+
];
|
|
702
|
+
}
|
|
703
|
+
maskInput(toolKey, input) {
|
|
704
|
+
return this.#hostVars.mask(
|
|
705
|
+
this.#toolCalls.maskInput(toolKey, parseInput(input)),
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
maskResult(content) {
|
|
709
|
+
return this.#maskSecrets(structuredClone(content));
|
|
710
|
+
}
|
|
711
|
+
maskNarration(text) {
|
|
712
|
+
return this.#maskSecrets(text);
|
|
713
|
+
}
|
|
714
|
+
#maskSecrets(value) {
|
|
715
|
+
return this.#hostVars.mask(this.#toolCalls.maskSecrets(value));
|
|
716
|
+
}
|
|
717
|
+
unmaskInput(toolKey, input) {
|
|
718
|
+
return this.#hostVars.unmask(
|
|
719
|
+
this.#toolCalls.unmaskInput(toolKey, parseInput(input)),
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// test/src/Recorder.ts
|
|
724
|
+
import {
|
|
725
|
+
ALUMNIUM_VERSION,
|
|
726
|
+
Telemetry as Telemetry5,
|
|
727
|
+
} from "@alumnium/core/internal";
|
|
728
|
+
|
|
729
|
+
// test/src/RecordingLeakError.ts
|
|
730
|
+
class RecordingLeakError extends Error {
|
|
731
|
+
masks;
|
|
732
|
+
constructor(masks) {
|
|
733
|
+
super(
|
|
734
|
+
`Recording would store values in clear: ${masks.join(", ")}. ` +
|
|
735
|
+
"Check the host variables and response paths configured for it.",
|
|
736
|
+
);
|
|
737
|
+
this.name = "RecordingLeakError";
|
|
738
|
+
this.masks = masks;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// test/src/Recorder.ts
|
|
743
|
+
var { logger: logger5 } = Telemetry5.get(
|
|
744
|
+
"packages/typescript/test/src/Recorder.ts",
|
|
745
|
+
);
|
|
746
|
+
var DEFAULT_ALUMNIUM_SERVER = "alumnium";
|
|
747
|
+
var ALUMNIUM_START_TOOL = "start";
|
|
748
|
+
|
|
749
|
+
class Recorder {
|
|
750
|
+
#text;
|
|
751
|
+
#agent;
|
|
752
|
+
#version;
|
|
753
|
+
#masker;
|
|
754
|
+
#steps = [];
|
|
755
|
+
#pendingCalls = new Map();
|
|
756
|
+
#recording;
|
|
757
|
+
constructor(props) {
|
|
758
|
+
const { text, agent, version, alumniumServer, ...maskerProps } = props;
|
|
759
|
+
this.#version = version ?? Recording.DEFAULT_VERSION;
|
|
760
|
+
this.#text = text;
|
|
761
|
+
this.#agent = agent;
|
|
762
|
+
const server = alumniumServer ?? DEFAULT_ALUMNIUM_SERVER;
|
|
763
|
+
this.#masker = new Masker({
|
|
764
|
+
...maskerProps,
|
|
765
|
+
registerResponsePaths: {
|
|
766
|
+
...this.#alumniumRegisterResponsePaths(server),
|
|
767
|
+
...maskerProps.registerResponsePaths,
|
|
768
|
+
},
|
|
769
|
+
wholeValueOnlyTools: [
|
|
770
|
+
`${server}/*`,
|
|
771
|
+
...(maskerProps.wholeValueOnlyTools ?? []),
|
|
772
|
+
],
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
onToolUse(use) {
|
|
776
|
+
this.#assertNotFinished();
|
|
777
|
+
const { id, server, tool, input } = use;
|
|
778
|
+
const toolKey = Recording.toolKey({ server, tool });
|
|
779
|
+
logger5.debug(`Recording tool use '${toolKey}' ({id})`, { id });
|
|
780
|
+
this.#pendingCalls.set(id, {
|
|
781
|
+
id,
|
|
782
|
+
...(server ? { server } : {}),
|
|
783
|
+
tool,
|
|
784
|
+
input: this.#masker.maskInput(toolKey, input),
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
onToolResult(toolResult) {
|
|
788
|
+
this.#assertNotFinished();
|
|
789
|
+
const { id, content } = toolResult;
|
|
790
|
+
const isError = Boolean(toolResult.isError);
|
|
791
|
+
const call = this.#pendingCalls.get(id);
|
|
792
|
+
if (!call) {
|
|
793
|
+
logger5.debug(`No recorded tool use for result '${id}', ignoring it`);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
this.#pendingCalls.delete(id);
|
|
797
|
+
logger5.debug(`Recording '${call.tool}' tool result`);
|
|
798
|
+
this.#masker.registerOutput(id, content, Recording.toolKey(call), {
|
|
799
|
+
secretsOnly: isError,
|
|
800
|
+
});
|
|
801
|
+
this.#steps.push({
|
|
802
|
+
kind: "tool-call",
|
|
803
|
+
call,
|
|
804
|
+
result: { content: structuredClone(content), isError },
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
onNarration(narration) {
|
|
808
|
+
this.#assertNotFinished();
|
|
809
|
+
if (!narration.text.trim()) return;
|
|
810
|
+
this.#steps.push({
|
|
811
|
+
kind: "narration",
|
|
812
|
+
narration: narration.narration,
|
|
813
|
+
text: narration.text,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
finish(verdict) {
|
|
817
|
+
this.#assertNotFinished();
|
|
818
|
+
if (this.#pendingCalls.size)
|
|
819
|
+
logger5.warn(
|
|
820
|
+
`Dropping ${this.#pendingCalls.size} tool calls with no result: ${[
|
|
821
|
+
...this.#pendingCalls.values(),
|
|
822
|
+
]
|
|
823
|
+
.map(({ tool }) => tool)
|
|
824
|
+
.join(", ")}`,
|
|
825
|
+
);
|
|
826
|
+
this.#pendingCalls.clear();
|
|
827
|
+
const recording = {
|
|
828
|
+
id: Recording.textToId(this.#text),
|
|
829
|
+
version: this.#version,
|
|
830
|
+
text: this.#text,
|
|
831
|
+
agent: this.#agent,
|
|
832
|
+
alumnium: { version: ALUMNIUM_VERSION },
|
|
833
|
+
hostVars: [],
|
|
834
|
+
steps: this.#steps.map((step) => this.#maskStep(step)),
|
|
835
|
+
verdict: {
|
|
836
|
+
status: verdict.status,
|
|
837
|
+
details: this.#masker.maskNarration(verdict.details),
|
|
838
|
+
},
|
|
839
|
+
};
|
|
840
|
+
const masked = [
|
|
841
|
+
recording.steps.map((step) =>
|
|
842
|
+
step.kind === "narration"
|
|
843
|
+
? step.text
|
|
844
|
+
: [step.call.input, step.result.content],
|
|
845
|
+
),
|
|
846
|
+
recording.verdict.details,
|
|
847
|
+
];
|
|
848
|
+
const leaks = this.#masker.findLeaks(masked);
|
|
849
|
+
if (leaks.length) throw new RecordingLeakError(leaks);
|
|
850
|
+
recording.hostVars = Masker.findHostVarNames(masked);
|
|
851
|
+
this.#recording = recording;
|
|
852
|
+
return recording;
|
|
853
|
+
}
|
|
854
|
+
#maskStep(step) {
|
|
855
|
+
if (step.kind === "narration") {
|
|
856
|
+
return { ...step, text: this.#masker.maskNarration(step.text) };
|
|
857
|
+
} else {
|
|
858
|
+
return {
|
|
859
|
+
...step,
|
|
860
|
+
result: {
|
|
861
|
+
...step.result,
|
|
862
|
+
content: this.#masker.maskResult(step.result.content),
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
#alumniumRegisterResponsePaths(server = DEFAULT_ALUMNIUM_SERVER) {
|
|
868
|
+
return {
|
|
869
|
+
[`${server}/*`]: [],
|
|
870
|
+
[`${server}/${ALUMNIUM_START_TOOL}`]: ["id"],
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
#assertNotFinished() {
|
|
874
|
+
if (this.#recording) throw new Error("Recording is already finished");
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
export { FileTestCache, Masker, Recorder, Recording, RecordingLeakError };
|
|
878
|
+
|
|
879
|
+
//# debugId=E1FEB499A312812964756E2164756E21
|
|
880
|
+
//# sourceMappingURL=index.js.map
|