@crewhaus/egress-classifier 0.1.4 → 0.1.6

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.
@@ -0,0 +1,171 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ import type { RunContext, TrustOrigin } from "@crewhaus/run-context";
3
+ export declare class EgressClassifierError extends CrewhausError {
4
+ readonly name = "EgressClassifierError";
5
+ constructor(message: string, cause?: unknown);
6
+ }
7
+ /**
8
+ * The classifier's three possible verdicts. Callers (runtime-core's
9
+ * pre-tool-call hook) inspect `action` and decide whether to block the
10
+ * call, log + proceed, or proceed silently.
11
+ */
12
+ export type EgressVerdict = "pass" | "warn" | "block";
13
+ /**
14
+ * Where the egress is going. `"external-configured"` means a sink the user
15
+ * explicitly wired in their spec (e.g. `tools: [fetch]` listed at compile
16
+ * time). `"external-dynamic"` means a sink discovered at runtime (e.g. an
17
+ * MCP server an agent registered mid-session, a federation peer that
18
+ * joined the swarm). Dynamic sinks default to stricter policy because the
19
+ * user never explicitly trusted them.
20
+ */
21
+ export type SinkScope = "external-configured" | "external-dynamic";
22
+ export type EgressResult = {
23
+ readonly verdict: EgressVerdict;
24
+ /** Origins of tagged content found in the payload, deduped. Empty when no hits. */
25
+ readonly originsFound: ReadonlyArray<TrustOrigin>;
26
+ /** Number of distinct tagged strings that matched. */
27
+ readonly matchCount: number;
28
+ /** Was this verdict served from cache? */
29
+ readonly fromCache: boolean;
30
+ /** Sink the egress was destined for; passed through for audit logging. */
31
+ readonly sinkId: string;
32
+ readonly sinkScope: SinkScope;
33
+ };
34
+ /**
35
+ * Minimum length for a tagged-content match to count. This is a BACKSTOP
36
+ * against pathological lineage entries, not the primary false-positive
37
+ * control: insertion discipline lives in run-context's `tagContent`, which
38
+ * only admits whole blobs / lines >= 16 chars and credential-shaped tokens
39
+ * >= 8 chars (audit follow-up R2 — see `MIN_TOKEN_TAG_LENGTH` and
40
+ * `isCredentialShaped` there). 8 matches the token floor so vetted short
41
+ * secrets (sk-..., hex runs, key=value secrets) can actually match at
42
+ * egress; anything shorter is indistinguishable from prose. Keep in sync
43
+ * with run-context's `MIN_TOKEN_TAG_LENGTH`.
44
+ */
45
+ export declare const MIN_MATCH_LENGTH = 8;
46
+ /**
47
+ * FR-006 — the matching step factored behind a strategy interface. The
48
+ * matcher decides *which* tagged lineage entries the outbound payload
49
+ * "contains"; it never decides pass/warn/block. The verdict fold (origin
50
+ * policy + `block > warn > pass` precedence) stays in `classifyEgress`, so
51
+ * the three audit outcomes and their precedence are structurally
52
+ * matcher-independent.
53
+ *
54
+ * The default `SubstringEgressMatcher` is behavior-preserving: it is the
55
+ * verbatim substring scan that lived inline before the seam existed,
56
+ * including the `MIN_MATCH_LENGTH` floor. An optional embedding-backed
57
+ * matcher ships separately as `@crewhaus/egress-matcher-semantic`; the
58
+ * default egress path never imports it (no new hard dependency).
59
+ *
60
+ * NOTE: the FR sketch wrote `match(payload, lineage, opts)` with
61
+ * `DataLineage` / `EgressOpts` types. Those names do not exist in the
62
+ * codebase (lineage is `Map<string, TrustOrigin>` on `RunContext`; there
63
+ * is no `DataLineage` type). This implementation uses a single
64
+ * `EgressMatchInput` bag — idiomatic with this codebase's option-bag style
65
+ * — and keeps the matcher returning only raw hits, which strictly
66
+ * strengthens the matcher-independence guarantee.
67
+ */
68
+ export type EgressMatchInput = {
69
+ /** The serialized outbound payload to inspect. */
70
+ readonly payload: string;
71
+ /** The run-context data-lineage map: tagged content → its trust origin. */
72
+ readonly lineage: ReadonlyMap<string, TrustOrigin>;
73
+ /** Floor below which a tagged entry is too short to count as a match. */
74
+ readonly minMatchLength: number;
75
+ };
76
+ /**
77
+ * Raw lineage hits — origins whose tagged content the matcher considers
78
+ * present in the payload, plus a count of distinct matched tagged strings.
79
+ * Deliberately verdict-free: `classifyEgress` folds policy over
80
+ * `originsFound`, the matcher does not.
81
+ */
82
+ export type EgressMatchResult = {
83
+ readonly originsFound: ReadonlyArray<TrustOrigin>;
84
+ readonly matchCount: number;
85
+ };
86
+ /**
87
+ * A pluggable egress-matching strategy. `name` namespaces audit/trace
88
+ * records and the verdict cache key (so a semantic-matcher verdict never
89
+ * serves a substring-matcher hit from cache). `match` may be sync or
90
+ * async; `classifyEgress` awaits it either way.
91
+ */
92
+ export interface EgressMatcher {
93
+ readonly name: string;
94
+ match(input: EgressMatchInput): EgressMatchResult | Promise<EgressMatchResult>;
95
+ }
96
+ /**
97
+ * The default egress matcher. A tagged entry counts when it is at least
98
+ * `minMatchLength` chars and appears in the payload OR in any of its
99
+ * normalized views (see `buildScanViews`) — so JSON-escaping and
100
+ * base64/hex/percent re-encoding can no longer slip a tagged secret past the
101
+ * sink-side fabric. The raw payload is always scanned first, so every match
102
+ * the old verbatim scan caught is still caught. `originsFound` is deduped;
103
+ * `matchCount` counts distinct matched tagged strings.
104
+ */
105
+ export declare class SubstringEgressMatcher implements EgressMatcher {
106
+ readonly name: string;
107
+ constructor();
108
+ match(input: EgressMatchInput): EgressMatchResult;
109
+ }
110
+ /** Shared default-matcher singleton — the built-in egress detection. */
111
+ export declare const substringMatcher: EgressMatcher;
112
+ export type EgressPolicyOverride = Partial<Record<TrustOrigin, EgressVerdict>>;
113
+ export type ClassifyEgressOptions = {
114
+ /**
115
+ * Stable identifier for the sink — usually `tool.name` (e.g. `"fetch"`,
116
+ * `"mcp:slack:send_message"`). Goes into the audit-log record so an
117
+ * incident investigator can trace which sink the egress was destined
118
+ * for without needing to reconstruct the call path.
119
+ */
120
+ readonly sinkId: string;
121
+ readonly sinkScope: SinkScope;
122
+ /**
123
+ * Per-origin severity override for this sink. Highest-precedence: a
124
+ * tool descriptor can carry `egressOverride: { subagent: "block" }` to
125
+ * tighten policy beyond defaults. Origins not listed fall back to
126
+ * `ORIGIN_DEFAULT_POLICY[origin][sinkScope]`.
127
+ */
128
+ readonly override?: EgressPolicyOverride;
129
+ /**
130
+ * Per-call cache bypass. Default false — production callers should
131
+ * leave caching on. Tests use `true` to assert classification fires.
132
+ */
133
+ readonly bypassCache?: boolean;
134
+ /**
135
+ * Minimum match length override. Tests and recipe demos use a smaller
136
+ * value to keep fixture payloads short. Production callers should not
137
+ * supply this.
138
+ */
139
+ readonly minMatchLength?: number;
140
+ /**
141
+ * FR-006 — pluggable matching strategy. Defaults to `substringMatcher`
142
+ * (behavior-preserving). Supply an alternate matcher (e.g. the optional
143
+ * `@crewhaus/egress-matcher-semantic`) to swap *how* lineage matches are
144
+ * detected; the per-origin/per-sink policy and the three audit outcomes
145
+ * are unaffected. The cache key namespaces by `matcher.name`, so
146
+ * switching matchers mid-run never cross-serves a stale verdict.
147
+ */
148
+ readonly matcher?: EgressMatcher;
149
+ };
150
+ /**
151
+ * The single chokepoint. Inspect `payload` for substring matches against
152
+ * any tagged content carried in `ctx.dataLineage`. For each match, look
153
+ * up the origin's policy under `sinkScope`. The folded verdict is the
154
+ * most-severe outcome across all hits.
155
+ *
156
+ * The classifier ALWAYS runs the scan. Override only controls what to do
157
+ * with the verdict. This means the audit trail records every non-pass
158
+ * outcome regardless of policy — honest audit even under permissive
159
+ * policy.
160
+ */
161
+ export declare function classifyEgress(payload: string, ctx: RunContext, opts: ClassifyEgressOptions): Promise<EgressResult>;
162
+ /**
163
+ * Build a redaction string for the audit log payload — the actual content
164
+ * is sensitive and should never be re-logged verbatim. Callers stamp this
165
+ * into the `payload_summary` field instead of the raw payload.
166
+ */
167
+ export declare function summarizeEgress(result: EgressResult): string;
168
+ /** Test/diagnostics only — clear the LRU between tests. */
169
+ export declare function _clearEgressCache(): void;
170
+ /** Test/diagnostics only — inspect cache size. */
171
+ export declare function _cacheSize(): number;
package/dist/index.js ADDED
@@ -0,0 +1,435 @@
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/55-egress-fabric.md.
47
+ */
48
+ import { createHash } from "node:crypto";
49
+ import { CrewhausError } from "@crewhaus/errors";
50
+ export class EgressClassifierError extends CrewhausError {
51
+ name = "EgressClassifierError";
52
+ constructor(message, cause) {
53
+ super("config", message, cause);
54
+ }
55
+ }
56
+ const ORIGIN_DEFAULT_POLICY = {
57
+ user: { "external-configured": "pass", "external-dynamic": "pass" },
58
+ mcp: { "external-configured": "warn", "external-dynamic": "block" },
59
+ subagent: { "external-configured": "warn", "external-dynamic": "block" },
60
+ channel: { "external-configured": "warn", "external-dynamic": "block" },
61
+ federation: { "external-configured": "warn", "external-dynamic": "block" },
62
+ skill: { "external-configured": "warn", "external-dynamic": "block" },
63
+ compaction: { "external-configured": "warn", "external-dynamic": "block" },
64
+ tool: { "external-configured": "warn", "external-dynamic": "block" },
65
+ chain: { "external-configured": "warn", "external-dynamic": "block" },
66
+ };
67
+ /**
68
+ * Minimum length for a tagged-content match to count. This is a BACKSTOP
69
+ * against pathological lineage entries, not the primary false-positive
70
+ * control: insertion discipline lives in run-context's `tagContent`, which
71
+ * only admits whole blobs / lines >= 16 chars and credential-shaped tokens
72
+ * >= 8 chars (audit follow-up R2 — see `MIN_TOKEN_TAG_LENGTH` and
73
+ * `isCredentialShaped` there). 8 matches the token floor so vetted short
74
+ * secrets (sk-..., hex runs, key=value secrets) can actually match at
75
+ * egress; anything shorter is indistinguishable from prose. Keep in sync
76
+ * with run-context's `MIN_TOKEN_TAG_LENGTH`.
77
+ */
78
+ export const MIN_MATCH_LENGTH = 8;
79
+ /**
80
+ * Decode-aware normalization so a tagged secret that was *re-encoded* before
81
+ * egress is still detected. A pure verbatim `payload.includes(tagged)` scan is
82
+ * trivially bypassed by a prompt-injectable model two ways:
83
+ *
84
+ * 1. JSON-escaping — runtime-core builds the egress payload as
85
+ * `JSON.stringify(toolInput)`, which escapes the newlines/quotes/
86
+ * backslashes that tagged MCP/sub-agent/channel content almost always
87
+ * contains, so the raw tagged string is no longer a substring.
88
+ * 2. base64/hex/percent re-encoding — the model emits `base64(secret)`
89
+ * instead of the plaintext.
90
+ *
91
+ * `buildScanViews` returns the payload plus normalized views (JSON-decoded
92
+ * string values, and base64/hex/percent-decoded blobs found in either form),
93
+ * and the matcher tests each tagged entry against ALL of them. The decoders
94
+ * mirror `@crewhaus/prompt-injection-detector` (replicated, not imported, to
95
+ * keep egress-classifier dependency-free; keep the copies in sync) and are
96
+ * bounded (match count + recursion depth) so this is not itself a DoS vector.
97
+ */
98
+ function isMostlyPrintable(s) {
99
+ if (s.length === 0)
100
+ return false;
101
+ let printable = 0;
102
+ for (let i = 0; i < s.length; i++) {
103
+ const c = s.charCodeAt(i);
104
+ if (c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127))
105
+ printable++;
106
+ }
107
+ return printable / s.length > 0.85;
108
+ }
109
+ function tryDecodeBase64(blob) {
110
+ if (blob.length < 16 || blob.length % 4 === 1)
111
+ return undefined;
112
+ try {
113
+ const decoded = Buffer.from(blob, "base64").toString("utf8");
114
+ return isMostlyPrintable(decoded) ? decoded : undefined;
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ function tryDecodeHex(blob) {
121
+ if (blob.length < 16 || blob.length % 2 !== 0)
122
+ return undefined;
123
+ try {
124
+ const decoded = Buffer.from(blob, "hex").toString("utf8");
125
+ return isMostlyPrintable(decoded) ? decoded : undefined;
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ }
131
+ function tryDecodePercent(text) {
132
+ try {
133
+ const decoded = decodeURIComponent(text);
134
+ return decoded !== text ? decoded : undefined;
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }
140
+ /** Recursively decode base64/hex/percent blobs. Bounded for DoS-safety. */
141
+ function decodedVariants(text, depth = 2) {
142
+ if (depth <= 0 || text.length === 0)
143
+ return [];
144
+ const out = [];
145
+ const push = (s) => {
146
+ if (s !== undefined && s.length > 0)
147
+ out.push(s, ...decodedVariants(s, depth - 1));
148
+ };
149
+ for (const m of [...text.matchAll(/[A-Za-z0-9+/]{16,}={0,2}/g)].slice(0, 8)) {
150
+ push(tryDecodeBase64(m[0]));
151
+ }
152
+ for (const m of [...text.matchAll(/(?:[0-9A-Fa-f]{2}){8,}/g)].slice(0, 8)) {
153
+ push(tryDecodeHex(m[0]));
154
+ }
155
+ if (/%[0-9A-Fa-f]{2}/.test(text))
156
+ push(tryDecodePercent(text));
157
+ return out.slice(0, 16);
158
+ }
159
+ /** Collect every string leaf of a parsed JSON value (bounded by JSON size). */
160
+ function collectJsonStrings(value, out) {
161
+ if (typeof value === "string") {
162
+ out.push(value);
163
+ return;
164
+ }
165
+ if (Array.isArray(value)) {
166
+ for (const v of value)
167
+ collectJsonStrings(v, out);
168
+ return;
169
+ }
170
+ if (value !== null && typeof value === "object") {
171
+ for (const v of Object.values(value))
172
+ collectJsonStrings(v, out);
173
+ }
174
+ }
175
+ /**
176
+ * The set of strings to scan a tagged entry against: the raw payload, the
177
+ * JSON-decoded string values (recovers content the `JSON.stringify` egress
178
+ * encoding escaped), and base64/hex/percent decodings of both.
179
+ */
180
+ function buildScanViews(payload) {
181
+ const views = [payload];
182
+ let jsonView;
183
+ try {
184
+ const parsed = JSON.parse(payload);
185
+ const strings = [];
186
+ collectJsonStrings(parsed, strings);
187
+ if (strings.length > 0)
188
+ jsonView = strings.join("\n");
189
+ }
190
+ catch {
191
+ // Not JSON — only the raw payload + its decodings are scanned.
192
+ }
193
+ if (jsonView !== undefined)
194
+ views.push(jsonView);
195
+ const decodeSources = jsonView !== undefined ? [payload, jsonView] : [payload];
196
+ for (const src of decodeSources) {
197
+ for (const v of decodedVariants(src))
198
+ views.push(v);
199
+ }
200
+ return views;
201
+ }
202
+ /**
203
+ * The default egress matcher. A tagged entry counts when it is at least
204
+ * `minMatchLength` chars and appears in the payload OR in any of its
205
+ * normalized views (see `buildScanViews`) — so JSON-escaping and
206
+ * base64/hex/percent re-encoding can no longer slip a tagged secret past the
207
+ * sink-side fabric. The raw payload is always scanned first, so every match
208
+ * the old verbatim scan caught is still caught. `originsFound` is deduped;
209
+ * `matchCount` counts distinct matched tagged strings.
210
+ */
211
+ export class SubstringEgressMatcher {
212
+ // Assigned in the constructor rather than as an inline field initializer:
213
+ // bun's coverage instruments a class-field initializer as its own function
214
+ // and (as of bun 1.3.x) cannot mark it covered, leaving an unreachable-by-
215
+ // tests gap in the function-coverage count. A plain constructor assignment
216
+ // is equivalent at runtime and is counted normally.
217
+ name;
218
+ constructor() {
219
+ this.name = "substring";
220
+ }
221
+ match(input) {
222
+ const views = buildScanViews(input.payload);
223
+ const seen = new Set();
224
+ let matchCount = 0;
225
+ for (const [tagged, origin] of input.lineage.entries()) {
226
+ if (tagged.length < input.minMatchLength)
227
+ continue;
228
+ if (views.some((view) => view.includes(tagged))) {
229
+ seen.add(origin);
230
+ matchCount += 1;
231
+ }
232
+ }
233
+ return { originsFound: [...seen], matchCount };
234
+ }
235
+ }
236
+ /** Shared default-matcher singleton — the built-in egress detection. */
237
+ export const substringMatcher = new SubstringEgressMatcher();
238
+ /**
239
+ * In-process LRU cache. Key = `sha256(sinkScope || sinkId || payload)`.
240
+ * Same cap as `boundary-classifier` so the two chokepoints have parallel
241
+ * memory budgets.
242
+ */
243
+ const DEFAULT_CACHE_CAP = 1024;
244
+ class LruCache {
245
+ cap;
246
+ map = new Map();
247
+ constructor(cap) {
248
+ this.cap = cap;
249
+ }
250
+ get(key) {
251
+ const value = this.map.get(key);
252
+ if (value !== undefined) {
253
+ this.map.delete(key);
254
+ this.map.set(key, value);
255
+ }
256
+ return value;
257
+ }
258
+ set(key, value) {
259
+ if (this.map.has(key))
260
+ this.map.delete(key);
261
+ this.map.set(key, value);
262
+ while (this.map.size > this.cap) {
263
+ const oldest = this.map.keys().next().value;
264
+ if (oldest === undefined)
265
+ break;
266
+ this.map.delete(oldest);
267
+ }
268
+ }
269
+ size() {
270
+ return this.map.size;
271
+ }
272
+ clear() {
273
+ this.map.clear();
274
+ }
275
+ }
276
+ const cache = new LruCache(DEFAULT_CACHE_CAP);
277
+ function cacheKey(payload, sinkScope, sinkId, matcherName, lineageDigest) {
278
+ // Length-prefix every field before hashing so the component boundaries are
279
+ // unambiguous. A bare `"|"` delimiter is not injective when a field can
280
+ // contain `"|"`: (sinkId="tool|", payload="x") and (sinkId="tool",
281
+ // payload="|x") would otherwise hash identically and cross-serve a cached
282
+ // verdict for a *different* payload — a cache-poisoning / egress-scan-bypass
283
+ // vector when sinkId carries attacker influence (e.g. a dynamically
284
+ // discovered MCP tool name). `<byteLength>:` framing makes each field
285
+ // self-delimiting regardless of its contents.
286
+ const h = createHash("sha256");
287
+ for (const field of [matcherName, sinkScope, sinkId, payload, lineageDigest]) {
288
+ h.update(String(Buffer.byteLength(field, "utf8")));
289
+ h.update(":");
290
+ h.update(field, "utf8");
291
+ }
292
+ return h.digest("hex");
293
+ }
294
+ /**
295
+ * Stable digest of the lineage map's CONTENT (keys + origins, sorted), used
296
+ * as a cache-key component. Without it the cache serves stale verdicts: the
297
+ * lineage map GROWS during a run (every boundary crossing tags more
298
+ * content), so the same (payload, sink) pair legitimately classifies
299
+ * differently once a secret contained in the payload gets tagged. A verdict
300
+ * cached before that tag would otherwise be served forever — an egress-scan
301
+ * bypass. Sorting makes the digest insensitive to recency-refresh reordering
302
+ * (delete + re-insert on re-tag), which changes Map iteration order without
303
+ * changing content.
304
+ */
305
+ function lineageDigestOf(lineage) {
306
+ const h = createHash("sha256");
307
+ const keys = [...lineage.keys()].sort();
308
+ for (const k of keys) {
309
+ h.update(String(Buffer.byteLength(k, "utf8")));
310
+ h.update(":");
311
+ h.update(k, "utf8");
312
+ h.update(lineage.get(k), "utf8");
313
+ }
314
+ return h.digest("hex");
315
+ }
316
+ /**
317
+ * Resolve the most-severe verdict for a set of origins under the given
318
+ * policy. `"block"` > `"warn"` > `"pass"`. Used to fold a list of origins
319
+ * (one per matched tagged-content hit) into a single decision.
320
+ */
321
+ function foldVerdict(verdicts) {
322
+ if (verdicts.some((v) => v === "block"))
323
+ return "block";
324
+ if (verdicts.some((v) => v === "warn"))
325
+ return "warn";
326
+ return "pass";
327
+ }
328
+ function originVerdict(origin, sinkScope, override) {
329
+ const o = override?.[origin];
330
+ if (o !== undefined)
331
+ return o;
332
+ return ORIGIN_DEFAULT_POLICY[origin][sinkScope];
333
+ }
334
+ /**
335
+ * The single chokepoint. Inspect `payload` for substring matches against
336
+ * any tagged content carried in `ctx.dataLineage`. For each match, look
337
+ * up the origin's policy under `sinkScope`. The folded verdict is the
338
+ * most-severe outcome across all hits.
339
+ *
340
+ * The classifier ALWAYS runs the scan. Override only controls what to do
341
+ * with the verdict. This means the audit trail records every non-pass
342
+ * outcome regardless of policy — honest audit even under permissive
343
+ * policy.
344
+ */
345
+ export async function classifyEgress(payload, ctx, opts) {
346
+ if (typeof payload !== "string") {
347
+ throw new EgressClassifierError(`classifyEgress expected a string payload, got ${typeof payload}`);
348
+ }
349
+ const lineage = ctx.dataLineage;
350
+ // No lineage tagging at all means nothing crossed a boundary yet — pass.
351
+ if (lineage === undefined || lineage.size === 0) {
352
+ return {
353
+ verdict: "pass",
354
+ originsFound: [],
355
+ matchCount: 0,
356
+ fromCache: false,
357
+ sinkId: opts.sinkId,
358
+ sinkScope: opts.sinkScope,
359
+ };
360
+ }
361
+ const floor = opts.minMatchLength ?? MIN_MATCH_LENGTH;
362
+ const matcher = opts.matcher ?? substringMatcher;
363
+ // Namespace the cache by matcher name so a verdict produced by one
364
+ // matcher (e.g. semantic) is never served to a call using another
365
+ // (e.g. substring) over the same (sinkScope, sinkId, payload) — and by a
366
+ // digest of the lineage content so a verdict computed against an OLDER,
367
+ // smaller lineage is never served after new tags land (see
368
+ // `lineageDigestOf`).
369
+ const key = cacheKey(payload, opts.sinkScope, opts.sinkId, matcher.name, lineageDigestOf(lineage));
370
+ if (opts.bypassCache !== true) {
371
+ const hit = cache.get(key);
372
+ if (hit !== undefined) {
373
+ // Re-evaluate the verdict under the *current* override (cache stores
374
+ // raw hits; the policy decision is cheap to recompute).
375
+ const verdicts = hit.originsFound.map((o) => originVerdict(o, opts.sinkScope, opts.override));
376
+ return {
377
+ verdict: foldVerdict(verdicts),
378
+ originsFound: hit.originsFound,
379
+ matchCount: hit.matchCount,
380
+ fromCache: true,
381
+ sinkId: opts.sinkId,
382
+ sinkScope: opts.sinkScope,
383
+ };
384
+ }
385
+ }
386
+ // The matcher decides *which* lineage entries the payload contains; the
387
+ // policy fold below is matcher-independent. `match` may be sync or async.
388
+ const { originsFound, matchCount } = await matcher.match({
389
+ payload,
390
+ lineage,
391
+ minMatchLength: floor,
392
+ });
393
+ const cached = { verdict: "pass", originsFound, matchCount };
394
+ if (opts.bypassCache !== true) {
395
+ cache.set(key, cached);
396
+ }
397
+ if (originsFound.length === 0) {
398
+ return {
399
+ verdict: "pass",
400
+ originsFound,
401
+ matchCount,
402
+ fromCache: false,
403
+ sinkId: opts.sinkId,
404
+ sinkScope: opts.sinkScope,
405
+ };
406
+ }
407
+ const verdicts = originsFound.map((o) => originVerdict(o, opts.sinkScope, opts.override));
408
+ return {
409
+ verdict: foldVerdict(verdicts),
410
+ originsFound,
411
+ matchCount,
412
+ fromCache: false,
413
+ sinkId: opts.sinkId,
414
+ sinkScope: opts.sinkScope,
415
+ };
416
+ }
417
+ /**
418
+ * Build a redaction string for the audit log payload — the actual content
419
+ * is sensitive and should never be re-logged verbatim. Callers stamp this
420
+ * into the `payload_summary` field instead of the raw payload.
421
+ */
422
+ export function summarizeEgress(result) {
423
+ if (result.originsFound.length === 0) {
424
+ return `clean (sink=${result.sinkId} scope=${result.sinkScope})`;
425
+ }
426
+ return `${result.verdict}: ${result.matchCount} match(es) from [${result.originsFound.join(",")}] (sink=${result.sinkId} scope=${result.sinkScope})`;
427
+ }
428
+ /** Test/diagnostics only — clear the LRU between tests. */
429
+ export function _clearEgressCache() {
430
+ cache.clear();
431
+ }
432
+ /** Test/diagnostics only — inspect cache size. */
433
+ export function _cacheSize() {
434
+ return cache.size();
435
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/egress-classifier",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
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",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4",
16
- "@crewhaus/run-context": "0.1.4"
18
+ "@crewhaus/errors": "0.1.6",
19
+ "@crewhaus/run-context": "0.1.6"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }