@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 +42 -0
- package/src/index.test.ts +197 -0
- package/src/index.ts +346 -0
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
|
+
}
|