@crewhaus/egress-classifier 0.1.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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@crewhaus/egress-classifier",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pillar-3 sink-side chokepoint — classify content leaving via external sinks (fetch / web / mcp / channel / federation / evm-tx) against the data-lineage carried in run-context",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0",
16
+ "@crewhaus/run-context": "0.0.0"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "author": {
20
+ "name": "Max Meier",
21
+ "email": "max@studiomax.io",
22
+ "url": "https://studiomax.io"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/crewhaus/factory.git",
27
+ "directory": "packages/egress-classifier"
28
+ },
29
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/egress-classifier#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/crewhaus/factory/issues"
32
+ },
33
+ "publishConfig": {
34
+ "access": "restricted"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "README.md",
39
+ "LICENSE",
40
+ "NOTICE"
41
+ ]
42
+ }
@@ -0,0 +1,197 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { type TrustOrigin, createRunContext, tagContent } from "@crewhaus/run-context";
3
+ import {
4
+ MIN_MATCH_LENGTH,
5
+ _cacheSize,
6
+ _clearEgressCache,
7
+ classifyEgress,
8
+ summarizeEgress,
9
+ } from "./index";
10
+
11
+ afterEach(() => {
12
+ _clearEgressCache();
13
+ });
14
+
15
+ describe("classifyEgress", () => {
16
+ test("returns pass when run-context has no dataLineage", async () => {
17
+ const ctx = createRunContext();
18
+ const result = await classifyEgress("any outbound payload", ctx, {
19
+ sinkId: "fetch",
20
+ sinkScope: "external-configured",
21
+ });
22
+ expect(result.verdict).toBe("pass");
23
+ expect(result.originsFound).toEqual([]);
24
+ expect(result.matchCount).toBe(0);
25
+ });
26
+
27
+ test("returns pass for user-origin content even at strict sink", async () => {
28
+ const ctx = createRunContext();
29
+ const tagged = "this is user-typed CLI input string";
30
+ tagContent(ctx, tagged, "user");
31
+ const result = await classifyEgress(`prefix ${tagged} suffix`, ctx, {
32
+ sinkId: "fetch",
33
+ sinkScope: "external-dynamic",
34
+ });
35
+ expect(result.verdict).toBe("pass");
36
+ expect(result.originsFound).toEqual(["user"]);
37
+ expect(result.matchCount).toBe(1);
38
+ });
39
+
40
+ test("warns when subagent content reaches a configured external sink", async () => {
41
+ const ctx = createRunContext();
42
+ const tagged = "API_KEY=sleeper-token-12345";
43
+ tagContent(ctx, tagged, "subagent");
44
+ const result = await classifyEgress(`POST body: ${tagged}`, ctx, {
45
+ sinkId: "fetch",
46
+ sinkScope: "external-configured",
47
+ });
48
+ expect(result.verdict).toBe("warn");
49
+ expect(result.originsFound).toEqual(["subagent"]);
50
+ });
51
+
52
+ test("blocks when subagent content reaches a dynamic external sink", async () => {
53
+ const ctx = createRunContext();
54
+ const tagged = "API_KEY=sleeper-token-12345";
55
+ tagContent(ctx, tagged, "subagent");
56
+ const result = await classifyEgress(`Bearer ${tagged}`, ctx, {
57
+ sinkId: "dynamic-mcp:foo",
58
+ sinkScope: "external-dynamic",
59
+ });
60
+ expect(result.verdict).toBe("block");
61
+ expect(result.originsFound).toEqual(["subagent"]);
62
+ });
63
+
64
+ test("ignores tagged content shorter than the match floor", async () => {
65
+ const ctx = createRunContext();
66
+ tagContent(ctx, "abc", "subagent"); // way under 16-char floor
67
+ const result = await classifyEgress("https://example.com/?q=abc", ctx, {
68
+ sinkId: "fetch",
69
+ sinkScope: "external-configured",
70
+ });
71
+ expect(result.verdict).toBe("pass");
72
+ expect(result.matchCount).toBe(0);
73
+ });
74
+
75
+ test("respects a custom minMatchLength for fixtures", async () => {
76
+ const ctx = createRunContext();
77
+ // tagContent itself enforces a 16-char floor to keep lineage clean, so
78
+ // for short-fixture tests we pre-populate dataLineage directly. In
79
+ // production, the classifier's floor and tagContent's floor are both
80
+ // 16; minMatchLength override is intended for tests + recipes.
81
+ ctx.dataLineage = new Map<string, TrustOrigin>([["shortish", "subagent"]]);
82
+ const result = await classifyEgress("payload shortish embedded", ctx, {
83
+ sinkId: "fetch",
84
+ sinkScope: "external-configured",
85
+ minMatchLength: 4,
86
+ });
87
+ expect(result.verdict).toBe("warn");
88
+ expect(result.matchCount).toBe(1);
89
+ });
90
+
91
+ test("folds to the most severe origin across multiple matches", async () => {
92
+ const ctx = createRunContext();
93
+ tagContent(ctx, "user-typed sentence here visible", "user");
94
+ tagContent(ctx, "mcp-sourced bearer token segment", "mcp");
95
+ const result = await classifyEgress(
96
+ "user-typed sentence here visible + mcp-sourced bearer token segment",
97
+ ctx,
98
+ {
99
+ sinkId: "dynamic-fetch",
100
+ sinkScope: "external-dynamic",
101
+ },
102
+ );
103
+ expect(result.verdict).toBe("block"); // mcp on dynamic-sink → block
104
+ expect(result.originsFound).toContain("user");
105
+ expect(result.originsFound).toContain("mcp");
106
+ });
107
+
108
+ test("override tightens policy beyond default", async () => {
109
+ const ctx = createRunContext();
110
+ tagContent(ctx, "subagent-flagged content from worker", "subagent");
111
+ const result = await classifyEgress("POST: subagent-flagged content from worker", ctx, {
112
+ sinkId: "fetch",
113
+ sinkScope: "external-configured", // default = warn
114
+ override: { subagent: "block" },
115
+ });
116
+ expect(result.verdict).toBe("block");
117
+ });
118
+
119
+ test("caches verdicts by (sinkScope, sinkId, payload)", async () => {
120
+ const ctx = createRunContext();
121
+ tagContent(ctx, "content tagged by subagent boundary", "subagent");
122
+ _clearEgressCache();
123
+ const first = await classifyEgress("POST content tagged by subagent boundary", ctx, {
124
+ sinkId: "fetch",
125
+ sinkScope: "external-configured",
126
+ });
127
+ expect(first.fromCache).toBe(false);
128
+ expect(_cacheSize()).toBe(1);
129
+
130
+ const second = await classifyEgress("POST content tagged by subagent boundary", ctx, {
131
+ sinkId: "fetch",
132
+ sinkScope: "external-configured",
133
+ });
134
+ expect(second.fromCache).toBe(true);
135
+ expect(second.verdict).toBe("warn");
136
+ });
137
+
138
+ test("cache bypass forces re-evaluation", async () => {
139
+ const ctx = createRunContext();
140
+ tagContent(ctx, "content tagged by subagent boundary", "subagent");
141
+ await classifyEgress("POST content tagged by subagent boundary", ctx, {
142
+ sinkId: "fetch",
143
+ sinkScope: "external-configured",
144
+ });
145
+ const re = await classifyEgress("POST content tagged by subagent boundary", ctx, {
146
+ sinkId: "fetch",
147
+ sinkScope: "external-configured",
148
+ bypassCache: true,
149
+ });
150
+ expect(re.fromCache).toBe(false);
151
+ });
152
+
153
+ test("rejects non-string payloads", async () => {
154
+ const ctx = createRunContext();
155
+ await expect(
156
+ // biome-ignore lint/suspicious/noExplicitAny: testing runtime guard
157
+ classifyEgress(123 as any, ctx, { sinkId: "fetch", sinkScope: "external-configured" }),
158
+ ).rejects.toThrow(/expected a string/);
159
+ });
160
+ });
161
+
162
+ describe("MIN_MATCH_LENGTH constant", () => {
163
+ test("is 16", () => {
164
+ expect(MIN_MATCH_LENGTH).toBe(16);
165
+ });
166
+ });
167
+
168
+ describe("summarizeEgress", () => {
169
+ test("formats a clean verdict for audit logs", () => {
170
+ const summary = summarizeEgress({
171
+ verdict: "pass",
172
+ originsFound: [],
173
+ matchCount: 0,
174
+ fromCache: false,
175
+ sinkId: "fetch",
176
+ sinkScope: "external-configured",
177
+ });
178
+ expect(summary).toContain("clean");
179
+ expect(summary).toContain("fetch");
180
+ expect(summary).toContain("external-configured");
181
+ });
182
+
183
+ test("formats a block verdict with origin list", () => {
184
+ const summary = summarizeEgress({
185
+ verdict: "block",
186
+ originsFound: ["mcp", "subagent"],
187
+ matchCount: 3,
188
+ fromCache: false,
189
+ sinkId: "dynamic-mcp:foo",
190
+ sinkScope: "external-dynamic",
191
+ });
192
+ expect(summary).toContain("block");
193
+ expect(summary).toContain("3");
194
+ expect(summary).toContain("mcp,subagent");
195
+ expect(summary).toContain("dynamic-mcp:foo");
196
+ });
197
+ });
package/src/index.ts ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Pillar 3 sink-side chokepoint — `egress-classifier`.
3
+ *
4
+ * `boundary-classifier` shipped the source half of the fabric: every cross-
5
+ * trust-domain ingress (MCP / sub-agent / channel / federation / skill /
6
+ * compaction / tool / chain) flows through `classifyBoundary(content, …)`,
7
+ * which tags the verdict with a `TrustOrigin` so downstream readers know
8
+ * *where* the content came from.
9
+ *
10
+ * That stops a malicious string from being silently absorbed into the
11
+ * model's context. It does **not** stop the agent from later transmitting
12
+ * that string to an external sink — a URL fetched, a channel message sent,
13
+ * a federation outbound payload, an MCP tool invocation. OpenAI's "Designing
14
+ * AI agents to resist prompt injection" (2026-05-08) and SACR's "Runtime
15
+ * Security for AI Agents" (2026) converge on the same conclusion:
16
+ * classification at the source is necessary but not sufficient. An attacker
17
+ * who controls a source AND an accessible sink can lateral-move across the
18
+ * agent's permissions even when every individual permission check passes.
19
+ *
20
+ * The egress classifier is the symmetric companion. Every external tool
21
+ * call (any tool with `scope: "external"` in the tool-catalog) routes its
22
+ * payload through `classifyEgress(payload, ctx, opts)` before invocation.
23
+ * The classifier looks up the run-context's `dataLineage` map (populated
24
+ * by `tagContent(ctx, content, origin)` at every boundary site) and checks
25
+ * whether the outbound payload contains substrings from non-`"user"`
26
+ * origins. A hit produces an `EgressVerdict`:
27
+ *
28
+ * - `"pass"` → no tagged content found OR origin policy is permissive
29
+ * - `"warn"` → tagged content found; log + emit audit event but proceed
30
+ * - `"block"` → tagged content found AND origin policy is strict; deny
31
+ *
32
+ * The default policy is **defense-in-depth, not defense-in-perimeter**:
33
+ * `"user"`-origin content always passes (the user can do whatever they want
34
+ * with their own data); content tagged from any other origin defaults to
35
+ * `"warn"` for sinks the user explicitly configured, and `"block"` for
36
+ * sinks reached through dynamic discovery (e.g., an MCP server the agent
37
+ * loaded mid-session, a federation peer it joined at runtime).
38
+ *
39
+ * Single-chokepoint design parity with `boundary-classifier`: the fabric
40
+ * only holds if every external-tool site uses the *same* classifier with
41
+ * the *same* policy. A new external tool that re-implements egress checks
42
+ * inline (or skips them for "performance") is a security regression, not
43
+ * a perf optimisation.
44
+ *
45
+ * Catalog layer: R8 (extension of §18 safety primitives, symmetric to
46
+ * `boundary-classifier`). Recipe: demos/walkthroughs/51-egress-fabric.md.
47
+ */
48
+ import { createHash } from "node:crypto";
49
+ import { CrewhausError } from "@crewhaus/errors";
50
+ import type { RunContext, TrustOrigin } from "@crewhaus/run-context";
51
+
52
+ export class EgressClassifierError extends CrewhausError {
53
+ override readonly name = "EgressClassifierError";
54
+ constructor(message: string, cause?: unknown) {
55
+ super("config", message, cause);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The classifier's three possible verdicts. Callers (runtime-core's
61
+ * pre-tool-call hook) inspect `action` and decide whether to block the
62
+ * call, log + proceed, or proceed silently.
63
+ */
64
+ export type EgressVerdict = "pass" | "warn" | "block";
65
+
66
+ /**
67
+ * Where the egress is going. `"external-configured"` means a sink the user
68
+ * explicitly wired in their spec (e.g. `tools: [fetch]` listed at compile
69
+ * time). `"external-dynamic"` means a sink discovered at runtime (e.g. an
70
+ * MCP server an agent registered mid-session, a federation peer that
71
+ * joined the swarm). Dynamic sinks default to stricter policy because the
72
+ * user never explicitly trusted them.
73
+ */
74
+ export type SinkScope = "external-configured" | "external-dynamic";
75
+
76
+ export type EgressResult = {
77
+ readonly verdict: EgressVerdict;
78
+ /** Origins of tagged content found in the payload, deduped. Empty when no hits. */
79
+ readonly originsFound: ReadonlyArray<TrustOrigin>;
80
+ /** Number of distinct tagged strings that matched. */
81
+ readonly matchCount: number;
82
+ /** Was this verdict served from cache? */
83
+ readonly fromCache: boolean;
84
+ /** Sink the egress was destined for; passed through for audit logging. */
85
+ readonly sinkId: string;
86
+ readonly sinkScope: SinkScope;
87
+ };
88
+
89
+ /**
90
+ * Per-origin default severity at egress time. `"user"` content is always
91
+ * pass — the user can do whatever they want with their own data. Every
92
+ * other origin defaults to `"warn"` on configured sinks (the user wired
93
+ * the sink in deliberately, but we still log + flag the audit trail) and
94
+ * `"block"` on dynamic sinks (the agent reached the sink without explicit
95
+ * spec authorisation; combining that with cross-origin data is too close
96
+ * to the social-engineering exfil pattern).
97
+ *
98
+ * Adding a new origin? Update both rows. The §41 `crewhaus doctor`
99
+ * philosophy-alignment check catches drift.
100
+ */
101
+ type SeverityMatrix = Record<TrustOrigin, Record<SinkScope, EgressVerdict>>;
102
+
103
+ const ORIGIN_DEFAULT_POLICY: SeverityMatrix = {
104
+ user: { "external-configured": "pass", "external-dynamic": "pass" },
105
+ mcp: { "external-configured": "warn", "external-dynamic": "block" },
106
+ subagent: { "external-configured": "warn", "external-dynamic": "block" },
107
+ channel: { "external-configured": "warn", "external-dynamic": "block" },
108
+ federation: { "external-configured": "warn", "external-dynamic": "block" },
109
+ skill: { "external-configured": "warn", "external-dynamic": "block" },
110
+ compaction: { "external-configured": "warn", "external-dynamic": "block" },
111
+ tool: { "external-configured": "warn", "external-dynamic": "block" },
112
+ chain: { "external-configured": "warn", "external-dynamic": "block" },
113
+ };
114
+
115
+ /**
116
+ * Minimum length for a tagged-content match to count. Short common
117
+ * strings (whitespace, single words, IDs ≤8 chars) produce too many
118
+ * false positives. 16 chars is the floor that empirically lets through
119
+ * benign overlap (`"the"`, `"https"`, short identifiers) while still
120
+ * catching meaningful exfil (URLs, tokens, sentences).
121
+ */
122
+ export const MIN_MATCH_LENGTH = 16;
123
+
124
+ export type EgressPolicyOverride = Partial<Record<TrustOrigin, EgressVerdict>>;
125
+
126
+ export type ClassifyEgressOptions = {
127
+ /**
128
+ * Stable identifier for the sink — usually `tool.name` (e.g. `"fetch"`,
129
+ * `"mcp:slack:send_message"`). Goes into the audit-log record so an
130
+ * incident investigator can trace which sink the egress was destined
131
+ * for without needing to reconstruct the call path.
132
+ */
133
+ readonly sinkId: string;
134
+ readonly sinkScope: SinkScope;
135
+ /**
136
+ * Per-origin severity override for this sink. Highest-precedence: a
137
+ * tool descriptor can carry `egressOverride: { subagent: "block" }` to
138
+ * tighten policy beyond defaults. Origins not listed fall back to
139
+ * `ORIGIN_DEFAULT_POLICY[origin][sinkScope]`.
140
+ */
141
+ readonly override?: EgressPolicyOverride;
142
+ /**
143
+ * Per-call cache bypass. Default false — production callers should
144
+ * leave caching on. Tests use `true` to assert classification fires.
145
+ */
146
+ readonly bypassCache?: boolean;
147
+ /**
148
+ * Minimum match length override. Tests and recipe demos use a smaller
149
+ * value to keep fixture payloads short. Production callers should not
150
+ * supply this.
151
+ */
152
+ readonly minMatchLength?: number;
153
+ };
154
+
155
+ /**
156
+ * In-process LRU cache. Key = `sha256(sinkScope || sinkId || payload)`.
157
+ * Same cap as `boundary-classifier` so the two chokepoints have parallel
158
+ * memory budgets.
159
+ */
160
+ const DEFAULT_CACHE_CAP = 1024;
161
+
162
+ class LruCache<V> {
163
+ private readonly map: Map<string, V> = new Map();
164
+ constructor(private readonly cap: number) {}
165
+ get(key: string): V | undefined {
166
+ const value = this.map.get(key);
167
+ if (value !== undefined) {
168
+ this.map.delete(key);
169
+ this.map.set(key, value);
170
+ }
171
+ return value;
172
+ }
173
+ set(key: string, value: V): void {
174
+ if (this.map.has(key)) this.map.delete(key);
175
+ this.map.set(key, value);
176
+ while (this.map.size > this.cap) {
177
+ const oldest = this.map.keys().next().value;
178
+ if (oldest === undefined) break;
179
+ this.map.delete(oldest);
180
+ }
181
+ }
182
+ has(key: string): boolean {
183
+ return this.map.has(key);
184
+ }
185
+ size(): number {
186
+ return this.map.size;
187
+ }
188
+ clear(): void {
189
+ this.map.clear();
190
+ }
191
+ }
192
+
193
+ type CachedVerdict = {
194
+ readonly verdict: EgressVerdict;
195
+ readonly originsFound: ReadonlyArray<TrustOrigin>;
196
+ readonly matchCount: number;
197
+ };
198
+
199
+ const cache = new LruCache<CachedVerdict>(DEFAULT_CACHE_CAP);
200
+
201
+ function cacheKey(payload: string, sinkScope: SinkScope, sinkId: string): string {
202
+ const h = createHash("sha256")
203
+ .update(sinkScope)
204
+ .update("|")
205
+ .update(sinkId)
206
+ .update("|")
207
+ .update(payload, "utf8")
208
+ .digest("hex");
209
+ return h;
210
+ }
211
+
212
+ /**
213
+ * Resolve the most-severe verdict for a set of origins under the given
214
+ * policy. `"block"` > `"warn"` > `"pass"`. Used to fold a list of origins
215
+ * (one per matched tagged-content hit) into a single decision.
216
+ */
217
+ function foldVerdict(verdicts: ReadonlyArray<EgressVerdict>): EgressVerdict {
218
+ if (verdicts.some((v) => v === "block")) return "block";
219
+ if (verdicts.some((v) => v === "warn")) return "warn";
220
+ return "pass";
221
+ }
222
+
223
+ function originVerdict(
224
+ origin: TrustOrigin,
225
+ sinkScope: SinkScope,
226
+ override?: EgressPolicyOverride,
227
+ ): EgressVerdict {
228
+ const o = override?.[origin];
229
+ if (o !== undefined) return o;
230
+ return ORIGIN_DEFAULT_POLICY[origin][sinkScope];
231
+ }
232
+
233
+ /**
234
+ * The single chokepoint. Inspect `payload` for substring matches against
235
+ * any tagged content carried in `ctx.dataLineage`. For each match, look
236
+ * up the origin's policy under `sinkScope`. The folded verdict is the
237
+ * most-severe outcome across all hits.
238
+ *
239
+ * The classifier ALWAYS runs the scan. Override only controls what to do
240
+ * with the verdict. This means the audit trail records every non-pass
241
+ * outcome regardless of policy — honest audit even under permissive
242
+ * policy.
243
+ */
244
+ export async function classifyEgress(
245
+ payload: string,
246
+ ctx: RunContext,
247
+ opts: ClassifyEgressOptions,
248
+ ): Promise<EgressResult> {
249
+ if (typeof payload !== "string") {
250
+ throw new EgressClassifierError(
251
+ `classifyEgress expected a string payload, got ${typeof payload}`,
252
+ );
253
+ }
254
+
255
+ const lineage = ctx.dataLineage;
256
+ // No lineage tagging at all means nothing crossed a boundary yet — pass.
257
+ if (lineage === undefined || lineage.size === 0) {
258
+ return {
259
+ verdict: "pass",
260
+ originsFound: [],
261
+ matchCount: 0,
262
+ fromCache: false,
263
+ sinkId: opts.sinkId,
264
+ sinkScope: opts.sinkScope,
265
+ };
266
+ }
267
+
268
+ const key = cacheKey(payload, opts.sinkScope, opts.sinkId);
269
+ if (opts.bypassCache !== true) {
270
+ const hit = cache.get(key);
271
+ if (hit !== undefined) {
272
+ // Re-evaluate the verdict under the *current* override (cache stores
273
+ // raw hits; the policy decision is cheap to recompute).
274
+ const verdicts = hit.originsFound.map((o) => originVerdict(o, opts.sinkScope, opts.override));
275
+ return {
276
+ verdict: foldVerdict(verdicts),
277
+ originsFound: hit.originsFound,
278
+ matchCount: hit.matchCount,
279
+ fromCache: true,
280
+ sinkId: opts.sinkId,
281
+ sinkScope: opts.sinkScope,
282
+ };
283
+ }
284
+ }
285
+
286
+ const floor = opts.minMatchLength ?? MIN_MATCH_LENGTH;
287
+ const seen = new Set<TrustOrigin>();
288
+ let matchCount = 0;
289
+
290
+ for (const [tagged, origin] of lineage.entries()) {
291
+ if (tagged.length < floor) continue;
292
+ if (payload.includes(tagged)) {
293
+ seen.add(origin);
294
+ matchCount += 1;
295
+ }
296
+ }
297
+
298
+ const originsFound: ReadonlyArray<TrustOrigin> = [...seen];
299
+ const cached: CachedVerdict = { verdict: "pass", originsFound, matchCount };
300
+ if (opts.bypassCache !== true) {
301
+ cache.set(key, cached);
302
+ }
303
+
304
+ if (originsFound.length === 0) {
305
+ return {
306
+ verdict: "pass",
307
+ originsFound,
308
+ matchCount,
309
+ fromCache: false,
310
+ sinkId: opts.sinkId,
311
+ sinkScope: opts.sinkScope,
312
+ };
313
+ }
314
+
315
+ const verdicts = originsFound.map((o) => originVerdict(o, opts.sinkScope, opts.override));
316
+ return {
317
+ verdict: foldVerdict(verdicts),
318
+ originsFound,
319
+ matchCount,
320
+ fromCache: false,
321
+ sinkId: opts.sinkId,
322
+ sinkScope: opts.sinkScope,
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Build a redaction string for the audit log payload — the actual content
328
+ * is sensitive and should never be re-logged verbatim. Callers stamp this
329
+ * into the `payload_summary` field instead of the raw payload.
330
+ */
331
+ export function summarizeEgress(result: EgressResult): string {
332
+ if (result.originsFound.length === 0) {
333
+ return `clean (sink=${result.sinkId} scope=${result.sinkScope})`;
334
+ }
335
+ return `${result.verdict}: ${result.matchCount} match(es) from [${result.originsFound.join(",")}] (sink=${result.sinkId} scope=${result.sinkScope})`;
336
+ }
337
+
338
+ /** Test/diagnostics only — clear the LRU between tests. */
339
+ export function _clearEgressCache(): void {
340
+ cache.clear();
341
+ }
342
+
343
+ /** Test/diagnostics only — inspect cache size. */
344
+ export function _cacheSize(): number {
345
+ return cache.size();
346
+ }