@crewhaus/egress-classifier 0.1.3 → 0.1.5
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/dist/index.d.ts +171 -0
- package/dist/index.js +435 -0
- package/package.json +10 -7
- package/src/coverage.test.ts +0 -486
- package/src/index.test.ts +0 -495
- package/src/index.ts +0 -605
package/src/coverage.test.ts
DELETED
|
@@ -1,486 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Supplemental coverage + hardening tests for `egress-classifier`.
|
|
3
|
-
*
|
|
4
|
-
* Companion to `index.test.ts`: the FR-006 acceptance suite there exercises
|
|
5
|
-
* the matcher seam and the headline pass/warn/block flows; this file drives
|
|
6
|
-
* the remaining branches (LRU eviction + recency, every policy-matrix cell,
|
|
7
|
-
* the cache-key framing regression, and the summarize/diagnostics helpers)
|
|
8
|
-
* to 100% and pins the security-relevant invariants.
|
|
9
|
-
*/
|
|
10
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
11
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
12
|
-
import { type TrustOrigin, createRunContext, tagContent } from "@crewhaus/run-context";
|
|
13
|
-
import {
|
|
14
|
-
EgressClassifierError,
|
|
15
|
-
type EgressMatcher,
|
|
16
|
-
type EgressResult,
|
|
17
|
-
MIN_MATCH_LENGTH,
|
|
18
|
-
type SinkScope,
|
|
19
|
-
SubstringEgressMatcher,
|
|
20
|
-
_cacheSize,
|
|
21
|
-
_clearEgressCache,
|
|
22
|
-
classifyEgress,
|
|
23
|
-
substringMatcher,
|
|
24
|
-
summarizeEgress,
|
|
25
|
-
} from "./index";
|
|
26
|
-
|
|
27
|
-
afterEach(() => {
|
|
28
|
-
_clearEgressCache();
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
// A trivially deterministic matcher that always reports the given origins.
|
|
32
|
-
function fixedMatcher(
|
|
33
|
-
name: string,
|
|
34
|
-
originsFound: TrustOrigin[],
|
|
35
|
-
matchCount = originsFound.length,
|
|
36
|
-
): EgressMatcher {
|
|
37
|
-
return { name, match: () => ({ originsFound, matchCount }) };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe("post-match (await) return paths with forced cache miss", () => {
|
|
41
|
-
test("bypassCache + matcher returns no hits → fresh pass after the await", async () => {
|
|
42
|
-
// Guarantees the cache-miss branch runs (bypassCache), the matcher is
|
|
43
|
-
// awaited, and the post-await `originsFound.length === 0` early return is
|
|
44
|
-
// taken — distinct from the no-lineage pre-await pass.
|
|
45
|
-
const ctx = createRunContext();
|
|
46
|
-
ctx.dataLineage = new Map<string, TrustOrigin>([["anything-present", "subagent"]]);
|
|
47
|
-
const r = await classifyEgress("outbound bytes", ctx, {
|
|
48
|
-
sinkId: "fetch",
|
|
49
|
-
sinkScope: "external-configured",
|
|
50
|
-
matcher: fixedMatcher("no-hits", [], 0),
|
|
51
|
-
bypassCache: true,
|
|
52
|
-
});
|
|
53
|
-
expect(r.verdict).toBe("pass");
|
|
54
|
-
expect(r.fromCache).toBe(false);
|
|
55
|
-
expect(r.originsFound).toEqual([]);
|
|
56
|
-
expect(r.matchCount).toBe(0);
|
|
57
|
-
expect(_cacheSize()).toBe(0); // bypassCache never wrote
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("bypassCache + matcher returns hits → fresh non-pass after the await", async () => {
|
|
61
|
-
const ctx = createRunContext();
|
|
62
|
-
ctx.dataLineage = new Map<string, TrustOrigin>([["anything-present", "subagent"]]);
|
|
63
|
-
const r = await classifyEgress("outbound bytes", ctx, {
|
|
64
|
-
sinkId: "fetch",
|
|
65
|
-
sinkScope: "external-dynamic",
|
|
66
|
-
matcher: fixedMatcher("has-hits", ["subagent"], 1),
|
|
67
|
-
bypassCache: true,
|
|
68
|
-
});
|
|
69
|
-
expect(r.verdict).toBe("block");
|
|
70
|
-
expect(r.fromCache).toBe(false);
|
|
71
|
-
expect(_cacheSize()).toBe(0);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("real substring path: cache miss, await, then non-empty return", async () => {
|
|
75
|
-
const ctx = createRunContext();
|
|
76
|
-
tagContent(ctx, "subagent payload that is verbatim present", "subagent");
|
|
77
|
-
const r = await classifyEgress("POST subagent payload that is verbatim present now", ctx, {
|
|
78
|
-
sinkId: "fetch",
|
|
79
|
-
sinkScope: "external-configured",
|
|
80
|
-
bypassCache: true,
|
|
81
|
-
});
|
|
82
|
-
expect(r.verdict).toBe("warn");
|
|
83
|
-
expect(r.fromCache).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("real substring path: cache miss, await, then empty return (no overlap)", async () => {
|
|
87
|
-
const ctx = createRunContext();
|
|
88
|
-
tagContent(ctx, "tagged content that will not appear", "subagent");
|
|
89
|
-
const r = await classifyEgress("a completely disjoint outbound string", ctx, {
|
|
90
|
-
sinkId: "fetch",
|
|
91
|
-
sinkScope: "external-configured",
|
|
92
|
-
bypassCache: true,
|
|
93
|
-
});
|
|
94
|
-
expect(r.verdict).toBe("pass");
|
|
95
|
-
expect(r.fromCache).toBe(false);
|
|
96
|
-
expect(r.originsFound).toEqual([]);
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe("policy matrix — every TrustOrigin × SinkScope cell", () => {
|
|
101
|
-
// Every non-user origin: warn on configured, block on dynamic.
|
|
102
|
-
const nonUser: TrustOrigin[] = [
|
|
103
|
-
"mcp",
|
|
104
|
-
"subagent",
|
|
105
|
-
"channel",
|
|
106
|
-
"federation",
|
|
107
|
-
"skill",
|
|
108
|
-
"compaction",
|
|
109
|
-
"tool",
|
|
110
|
-
"chain",
|
|
111
|
-
];
|
|
112
|
-
|
|
113
|
-
for (const origin of nonUser) {
|
|
114
|
-
test(`${origin}: configured → warn, dynamic → block`, async () => {
|
|
115
|
-
const ctx = createRunContext();
|
|
116
|
-
ctx.dataLineage = new Map<string, TrustOrigin>([["anything-tagged", origin]]);
|
|
117
|
-
const m = fixedMatcher(`fixed-${origin}`, [origin], 1);
|
|
118
|
-
const configured = await classifyEgress("payload", ctx, {
|
|
119
|
-
sinkId: "fetch",
|
|
120
|
-
sinkScope: "external-configured",
|
|
121
|
-
matcher: m,
|
|
122
|
-
bypassCache: true,
|
|
123
|
-
});
|
|
124
|
-
const dynamic = await classifyEgress("payload", ctx, {
|
|
125
|
-
sinkId: "dyn",
|
|
126
|
-
sinkScope: "external-dynamic",
|
|
127
|
-
matcher: m,
|
|
128
|
-
bypassCache: true,
|
|
129
|
-
});
|
|
130
|
-
expect(configured.verdict).toBe("warn");
|
|
131
|
-
expect(dynamic.verdict).toBe("block");
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
test("user: pass on both configured and dynamic", async () => {
|
|
136
|
-
const ctx = createRunContext();
|
|
137
|
-
ctx.dataLineage = new Map<string, TrustOrigin>([["anything-tagged", "user"]]);
|
|
138
|
-
const m = fixedMatcher("fixed-user", ["user"], 1);
|
|
139
|
-
for (const sinkScope of ["external-configured", "external-dynamic"] as SinkScope[]) {
|
|
140
|
-
const r = await classifyEgress("payload", ctx, {
|
|
141
|
-
sinkId: "s",
|
|
142
|
-
sinkScope,
|
|
143
|
-
matcher: m,
|
|
144
|
-
bypassCache: true,
|
|
145
|
-
});
|
|
146
|
-
expect(r.verdict).toBe("pass");
|
|
147
|
-
expect(r.originsFound).toEqual(["user"]);
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
describe("foldVerdict precedence (via classifyEgress)", () => {
|
|
153
|
-
test("warn wins over pass when no block present", async () => {
|
|
154
|
-
// user (pass) + tool@configured (warn) → warn, exercising the
|
|
155
|
-
// `some(warn)` branch after `some(block)` short-circuits to false.
|
|
156
|
-
const ctx = createRunContext();
|
|
157
|
-
ctx.dataLineage = new Map<string, TrustOrigin>([
|
|
158
|
-
["one", "user"],
|
|
159
|
-
["two", "tool"],
|
|
160
|
-
]);
|
|
161
|
-
const m = fixedMatcher("multi", ["user", "tool"], 2);
|
|
162
|
-
const r = await classifyEgress("payload", ctx, {
|
|
163
|
-
sinkId: "fetch",
|
|
164
|
-
sinkScope: "external-configured",
|
|
165
|
-
matcher: m,
|
|
166
|
-
});
|
|
167
|
-
expect(r.verdict).toBe("warn");
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
test("block wins over warn", async () => {
|
|
171
|
-
const ctx = createRunContext();
|
|
172
|
-
ctx.dataLineage = new Map<string, TrustOrigin>([
|
|
173
|
-
["one", "user"],
|
|
174
|
-
["two", "mcp"],
|
|
175
|
-
]);
|
|
176
|
-
// user → pass, mcp@dynamic → block; folded = block.
|
|
177
|
-
const m = fixedMatcher("multi2", ["user", "mcp"], 2);
|
|
178
|
-
const r = await classifyEgress("payload", ctx, {
|
|
179
|
-
sinkId: "dyn",
|
|
180
|
-
sinkScope: "external-dynamic",
|
|
181
|
-
matcher: m,
|
|
182
|
-
});
|
|
183
|
-
expect(r.verdict).toBe("block");
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test("all-pass origins fold to pass", async () => {
|
|
187
|
-
// Two user hits → foldVerdict reaches the trailing `return "pass"`.
|
|
188
|
-
const ctx = createRunContext();
|
|
189
|
-
ctx.dataLineage = new Map<string, TrustOrigin>([
|
|
190
|
-
["one", "user"],
|
|
191
|
-
["two", "user"],
|
|
192
|
-
]);
|
|
193
|
-
const m = fixedMatcher("two-user", ["user", "user"], 2);
|
|
194
|
-
const r = await classifyEgress("payload", ctx, {
|
|
195
|
-
sinkId: "fetch",
|
|
196
|
-
sinkScope: "external-dynamic",
|
|
197
|
-
matcher: m,
|
|
198
|
-
});
|
|
199
|
-
expect(r.verdict).toBe("pass");
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
describe("override semantics", () => {
|
|
204
|
-
test("override can loosen a default-block to pass on a dynamic sink", async () => {
|
|
205
|
-
const ctx = createRunContext();
|
|
206
|
-
tagContent(ctx, "mcp-sourced content from a server", "mcp");
|
|
207
|
-
const r = await classifyEgress("body: mcp-sourced content from a server", ctx, {
|
|
208
|
-
sinkId: "dyn-mcp",
|
|
209
|
-
sinkScope: "external-dynamic", // default mcp@dynamic = block
|
|
210
|
-
override: { mcp: "pass" },
|
|
211
|
-
});
|
|
212
|
-
expect(r.verdict).toBe("pass");
|
|
213
|
-
expect(r.originsFound).toEqual(["mcp"]);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
test("override only applies to listed origins; others keep defaults", async () => {
|
|
217
|
-
const ctx = createRunContext();
|
|
218
|
-
ctx.dataLineage = new Map<string, TrustOrigin>([
|
|
219
|
-
["one", "subagent"],
|
|
220
|
-
["two", "channel"],
|
|
221
|
-
]);
|
|
222
|
-
const m = fixedMatcher("two-origin", ["subagent", "channel"], 2);
|
|
223
|
-
// Loosen subagent to pass, leave channel at its dynamic default (block).
|
|
224
|
-
const r = await classifyEgress("payload", ctx, {
|
|
225
|
-
sinkId: "dyn",
|
|
226
|
-
sinkScope: "external-dynamic",
|
|
227
|
-
override: { subagent: "pass" },
|
|
228
|
-
matcher: m,
|
|
229
|
-
});
|
|
230
|
-
expect(r.verdict).toBe("block"); // channel still blocks
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test("cached verdict is re-folded under a different override on the second call", async () => {
|
|
234
|
-
// First call caches the raw hit (subagent) with no override. Second call
|
|
235
|
-
// serves from cache but recomputes the verdict under a tightening
|
|
236
|
-
// override — exercising the cache-hit `.map(originVerdict)` arrow.
|
|
237
|
-
const ctx = createRunContext();
|
|
238
|
-
const tagged = "subagent content for cache reeval test";
|
|
239
|
-
tagContent(ctx, tagged, "subagent");
|
|
240
|
-
const first = await classifyEgress(`x ${tagged}`, ctx, {
|
|
241
|
-
sinkId: "fetch",
|
|
242
|
-
sinkScope: "external-configured", // warn
|
|
243
|
-
});
|
|
244
|
-
expect(first.fromCache).toBe(false);
|
|
245
|
-
expect(first.verdict).toBe("warn");
|
|
246
|
-
|
|
247
|
-
const second = await classifyEgress(`x ${tagged}`, ctx, {
|
|
248
|
-
sinkId: "fetch",
|
|
249
|
-
sinkScope: "external-configured",
|
|
250
|
-
override: { subagent: "block" }, // tighten
|
|
251
|
-
});
|
|
252
|
-
expect(second.fromCache).toBe(true);
|
|
253
|
-
expect(second.verdict).toBe("block");
|
|
254
|
-
// The cached raw hit is preserved even though the folded verdict changed.
|
|
255
|
-
expect(second.originsFound).toEqual(["subagent"]);
|
|
256
|
-
expect(second.matchCount).toBe(1);
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
describe("cache-key framing (regression: delimiter-collision exfil bypass)", () => {
|
|
261
|
-
test("shifted sinkId/payload boundary does NOT cross-serve a cached verdict", async () => {
|
|
262
|
-
// CONSTRUCT A TRUE COLLISION for the old bare-`|` key scheme. With
|
|
263
|
-
// matcher+scope held constant, these two calls byte-concatenate the same
|
|
264
|
-
// `sinkId|payload` stream:
|
|
265
|
-
// A: sinkId = "tool|", payload = P → "…|tool||P"
|
|
266
|
-
// B: sinkId = "tool", payload = "|" + P → "…|tool||P"
|
|
267
|
-
// Under the vulnerable key, B would hash-collide with A and be served A's
|
|
268
|
-
// cached entry (fromCache:true, cache size stays 1) — a cache-poisoning /
|
|
269
|
-
// egress-scan bypass when sinkId carries attacker influence (e.g. a
|
|
270
|
-
// dynamically discovered MCP tool name). Length-framed keys make the two
|
|
271
|
-
// self-delimiting and therefore distinct.
|
|
272
|
-
const ctx = createRunContext();
|
|
273
|
-
// Lineage must be non-empty so the classifier reaches the cache/match path
|
|
274
|
-
// (an empty lineage short-circuits to pass before any key is computed).
|
|
275
|
-
ctx.dataLineage = new Map<string, TrustOrigin>([["present-tag-entry", "subagent"]]);
|
|
276
|
-
const P = "shared-suffix outbound payload bytes";
|
|
277
|
-
// Use a matcher whose result is independent of payload so the two calls'
|
|
278
|
-
// verdicts would coincide — isolating `fromCache`/size as the sole tell.
|
|
279
|
-
const m = fixedMatcher("framing", ["subagent"], 1);
|
|
280
|
-
|
|
281
|
-
const a = await classifyEgress(P, ctx, {
|
|
282
|
-
sinkId: "tool|",
|
|
283
|
-
sinkScope: "external-configured",
|
|
284
|
-
matcher: m,
|
|
285
|
-
});
|
|
286
|
-
expect(a.fromCache).toBe(false);
|
|
287
|
-
expect(_cacheSize()).toBe(1);
|
|
288
|
-
|
|
289
|
-
const b = await classifyEgress(`|${P}`, ctx, {
|
|
290
|
-
sinkId: "tool",
|
|
291
|
-
sinkScope: "external-configured",
|
|
292
|
-
matcher: m,
|
|
293
|
-
});
|
|
294
|
-
// The discriminator: on the fixed key B is a fresh miss (its own slot);
|
|
295
|
-
// on the vulnerable key B would have been served A's entry.
|
|
296
|
-
expect(b.fromCache).toBe(false);
|
|
297
|
-
expect(_cacheSize()).toBe(2);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
test("identical (matcher, scope, sinkId, payload) still hits cache", async () => {
|
|
301
|
-
const ctx = createRunContext();
|
|
302
|
-
tagContent(ctx, "subagent content stable for cache", "subagent");
|
|
303
|
-
const p = "POST subagent content stable for cache";
|
|
304
|
-
const first = await classifyEgress(p, ctx, {
|
|
305
|
-
sinkId: "fetch",
|
|
306
|
-
sinkScope: "external-configured",
|
|
307
|
-
});
|
|
308
|
-
const second = await classifyEgress(p, ctx, {
|
|
309
|
-
sinkId: "fetch",
|
|
310
|
-
sinkScope: "external-configured",
|
|
311
|
-
});
|
|
312
|
-
expect(first.fromCache).toBe(false);
|
|
313
|
-
expect(second.fromCache).toBe(true);
|
|
314
|
-
expect(_cacheSize()).toBe(1);
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test("a literal '|' inside sinkId does not collide with a different split", async () => {
|
|
318
|
-
// Direct key-injectivity check at the classifier boundary: same payload,
|
|
319
|
-
// sinkIds "a|b" vs "a" with payload prefixed — must be two cache slots.
|
|
320
|
-
const ctx = createRunContext();
|
|
321
|
-
tagContent(ctx, "subagent content for framing test ok", "subagent");
|
|
322
|
-
await classifyEgress("subagent content for framing test ok", ctx, {
|
|
323
|
-
sinkId: "a|b",
|
|
324
|
-
sinkScope: "external-configured",
|
|
325
|
-
});
|
|
326
|
-
await classifyEgress("subagent content for framing test ok", ctx, {
|
|
327
|
-
sinkId: "a",
|
|
328
|
-
sinkScope: "external-configured",
|
|
329
|
-
});
|
|
330
|
-
expect(_cacheSize()).toBe(2);
|
|
331
|
-
});
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
describe("LRU cache behaviour", () => {
|
|
335
|
-
test("distinct payloads accumulate distinct entries", async () => {
|
|
336
|
-
const ctx = createRunContext();
|
|
337
|
-
tagContent(ctx, "subagent content for lru accumulation", "subagent");
|
|
338
|
-
for (let i = 0; i < 5; i++) {
|
|
339
|
-
await classifyEgress(`payload number ${i} subagent content for lru accumulation`, ctx, {
|
|
340
|
-
sinkId: "fetch",
|
|
341
|
-
sinkScope: "external-configured",
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
expect(_cacheSize()).toBe(5);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
test("re-accessing an entry refreshes its recency (get path)", async () => {
|
|
348
|
-
// Exercises LruCache.get's move-to-end recency bump: an entry read on the
|
|
349
|
-
// second call survives even as new entries arrive.
|
|
350
|
-
const ctx = createRunContext();
|
|
351
|
-
tagContent(ctx, "subagent recency probe content here", "subagent");
|
|
352
|
-
const p0 = "first subagent recency probe content here";
|
|
353
|
-
const r1 = await classifyEgress(p0, ctx, { sinkId: "fetch", sinkScope: "external-configured" });
|
|
354
|
-
expect(r1.fromCache).toBe(false);
|
|
355
|
-
// Touch p0 again → cache hit, recency refreshed.
|
|
356
|
-
const r2 = await classifyEgress(p0, ctx, { sinkId: "fetch", sinkScope: "external-configured" });
|
|
357
|
-
expect(r2.fromCache).toBe(true);
|
|
358
|
-
expect(_cacheSize()).toBe(1);
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
test("bypassCache never populates the cache (no store on miss)", async () => {
|
|
362
|
-
const ctx = createRunContext();
|
|
363
|
-
tagContent(ctx, "subagent content under bypass mode here", "subagent");
|
|
364
|
-
await classifyEgress("x subagent content under bypass mode here", ctx, {
|
|
365
|
-
sinkId: "fetch",
|
|
366
|
-
sinkScope: "external-configured",
|
|
367
|
-
bypassCache: true,
|
|
368
|
-
});
|
|
369
|
-
expect(_cacheSize()).toBe(0);
|
|
370
|
-
});
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
describe("summarizeEgress", () => {
|
|
374
|
-
const base: Omit<EgressResult, "verdict" | "originsFound" | "matchCount"> = {
|
|
375
|
-
fromCache: false,
|
|
376
|
-
sinkId: "fetch",
|
|
377
|
-
sinkScope: "external-configured",
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
test("clean summary when no origins matched", () => {
|
|
381
|
-
const s = summarizeEgress({ ...base, verdict: "pass", originsFound: [], matchCount: 0 });
|
|
382
|
-
expect(s).toBe("clean (sink=fetch scope=external-configured)");
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
test("warn summary lists origins and count", () => {
|
|
386
|
-
const s = summarizeEgress({
|
|
387
|
-
...base,
|
|
388
|
-
verdict: "warn",
|
|
389
|
-
originsFound: ["subagent"],
|
|
390
|
-
matchCount: 1,
|
|
391
|
-
sinkScope: "external-configured",
|
|
392
|
-
});
|
|
393
|
-
expect(s).toBe("warn: 1 match(es) from [subagent] (sink=fetch scope=external-configured)");
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
test("block summary with multiple origins joins with commas", () => {
|
|
397
|
-
const s = summarizeEgress({
|
|
398
|
-
verdict: "block",
|
|
399
|
-
originsFound: ["mcp", "federation"],
|
|
400
|
-
matchCount: 4,
|
|
401
|
-
fromCache: true,
|
|
402
|
-
sinkId: "dyn:peer",
|
|
403
|
-
sinkScope: "external-dynamic",
|
|
404
|
-
});
|
|
405
|
-
expect(s).toBe(
|
|
406
|
-
"block: 4 match(es) from [mcp,federation] (sink=dyn:peer scope=external-dynamic)",
|
|
407
|
-
);
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
describe("SubstringEgressMatcher direct", () => {
|
|
412
|
-
test("empty lineage yields no hits", () => {
|
|
413
|
-
const r = new SubstringEgressMatcher().match({
|
|
414
|
-
payload: "anything at all goes here",
|
|
415
|
-
lineage: new Map(),
|
|
416
|
-
minMatchLength: MIN_MATCH_LENGTH,
|
|
417
|
-
});
|
|
418
|
-
expect(r.originsFound).toEqual([]);
|
|
419
|
-
expect(r.matchCount).toBe(0);
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
test("dedupes origins but counts distinct matched strings", () => {
|
|
423
|
-
const lineage = new Map<string, TrustOrigin>([
|
|
424
|
-
["first tagged string over floor", "subagent"],
|
|
425
|
-
["second tagged string over floor", "subagent"],
|
|
426
|
-
]);
|
|
427
|
-
const r = new SubstringEgressMatcher().match({
|
|
428
|
-
payload: "first tagged string over floor and second tagged string over floor",
|
|
429
|
-
lineage,
|
|
430
|
-
minMatchLength: MIN_MATCH_LENGTH,
|
|
431
|
-
});
|
|
432
|
-
expect(r.originsFound).toEqual(["subagent"]); // deduped
|
|
433
|
-
expect(r.matchCount).toBe(2); // two distinct strings
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
test("singleton and class share the same name", () => {
|
|
437
|
-
expect(substringMatcher.name).toBe("substring");
|
|
438
|
-
});
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
describe("diagnostics helpers", () => {
|
|
442
|
-
test("_clearEgressCache empties the cache", async () => {
|
|
443
|
-
const ctx = createRunContext();
|
|
444
|
-
tagContent(ctx, "subagent content to populate cache now", "subagent");
|
|
445
|
-
await classifyEgress("x subagent content to populate cache now", ctx, {
|
|
446
|
-
sinkId: "fetch",
|
|
447
|
-
sinkScope: "external-configured",
|
|
448
|
-
});
|
|
449
|
-
expect(_cacheSize()).toBeGreaterThan(0);
|
|
450
|
-
_clearEgressCache();
|
|
451
|
-
expect(_cacheSize()).toBe(0);
|
|
452
|
-
});
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
describe("EgressClassifierError", () => {
|
|
456
|
-
test("carries the config error code, fixed name, message, and cause chain", () => {
|
|
457
|
-
const cause = new Error("root");
|
|
458
|
-
const err = new EgressClassifierError("boom", cause);
|
|
459
|
-
// The `name` field initializer + constructor (the class's two functions)
|
|
460
|
-
// are exercised directly here, independent of the internal throw site.
|
|
461
|
-
expect(err.name).toBe("EgressClassifierError");
|
|
462
|
-
expect(err.message).toBe("boom");
|
|
463
|
-
expect(err.code).toBe("config");
|
|
464
|
-
expect(err.cause).toBe(cause);
|
|
465
|
-
expect(err).toBeInstanceOf(EgressClassifierError);
|
|
466
|
-
expect(err).toBeInstanceOf(CrewhausError);
|
|
467
|
-
expect(err).toBeInstanceOf(Error);
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
test("cause is optional", () => {
|
|
471
|
-
const err = new EgressClassifierError("no cause");
|
|
472
|
-
expect(err.cause).toBeUndefined();
|
|
473
|
-
expect(err.name).toBe("EgressClassifierError");
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
test("classifyEgress throws an EgressClassifierError for a non-string payload", async () => {
|
|
477
|
-
const ctx = createRunContext();
|
|
478
|
-
await expect(
|
|
479
|
-
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
480
|
-
classifyEgress({ not: "a string" } as any, ctx, {
|
|
481
|
-
sinkId: "fetch",
|
|
482
|
-
sinkScope: "external-configured",
|
|
483
|
-
}),
|
|
484
|
-
).rejects.toBeInstanceOf(EgressClassifierError);
|
|
485
|
-
});
|
|
486
|
-
});
|