@datafog/fogclaw 0.1.5 → 0.2.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/CHANGELOG.md +31 -0
- package/README.md +83 -4
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +100 -1
- package/dist/config.js.map +1 -1
- package/dist/extract.d.ts +28 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +91 -0
- package/dist/extract.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +135 -30
- package/dist/index.js.map +1 -1
- package/dist/message-sending-handler.d.ts +40 -0
- package/dist/message-sending-handler.d.ts.map +1 -0
- package/dist/message-sending-handler.js +50 -0
- package/dist/message-sending-handler.js.map +1 -0
- package/dist/scanner.d.ts +13 -2
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +76 -2
- package/dist/scanner.js.map +1 -1
- package/dist/tool-result-handler.d.ts +36 -0
- package/dist/tool-result-handler.d.ts.map +1 -0
- package/dist/tool-result-handler.js +91 -0
- package/dist/tool-result-handler.js.map +1 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -1
- package/docs/OBSERVABILITY.md +22 -15
- package/docs/SECURITY.md +6 -4
- package/docs/plans/active/2026-02-17-feat-tool-result-pii-scanning-plan.md +293 -0
- package/docs/specs/2026-02-17-feat-outbound-message-pii-scanning-spec.md +93 -0
- package/docs/specs/2026-02-17-feat-tool-result-pii-scanning-spec.md +122 -0
- package/fogclaw.config.example.json +19 -1
- package/openclaw.plugin.json +63 -2
- package/package.json +9 -9
- package/scripts/ci/he-docs-drift.sh +0 -0
- package/scripts/ci/he-docs-lint.sh +0 -0
- package/scripts/ci/he-plans-lint.sh +0 -0
- package/scripts/ci/he-runbooks-lint.sh +0 -0
- package/scripts/ci/he-specs-lint.sh +0 -0
- package/scripts/ci/he-spikes-lint.sh +0 -0
- package/scripts/runbooks/select-runbooks.sh +0 -0
- package/src/config.ts +139 -2
- package/src/extract.ts +98 -0
- package/src/index.ts +194 -36
- package/src/message-sending-handler.ts +87 -0
- package/src/scanner.ts +114 -8
- package/src/tool-result-handler.ts +133 -0
- package/src/types.ts +23 -0
- package/tests/config.test.ts +55 -81
- package/tests/extract.test.ts +185 -0
- package/tests/message-sending-handler.test.ts +244 -0
- package/tests/plugin-smoke.test.ts +139 -3
- package/tests/scanner.test.ts +61 -1
- package/tests/tool-result-handler.test.ts +329 -0
package/src/types.ts
CHANGED
|
@@ -11,6 +11,16 @@ export type RedactStrategy = "token" | "mask" | "hash";
|
|
|
11
11
|
|
|
12
12
|
export type GuardrailAction = "redact" | "block" | "warn";
|
|
13
13
|
|
|
14
|
+
export interface EntityConfidenceThresholds {
|
|
15
|
+
[entityType: string]: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EntityAllowlist {
|
|
19
|
+
values: string[];
|
|
20
|
+
patterns: string[];
|
|
21
|
+
entities: Record<string, string[]>;
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
export interface FogClawConfig {
|
|
15
25
|
enabled: boolean;
|
|
16
26
|
guardrail_mode: GuardrailAction;
|
|
@@ -19,6 +29,9 @@ export interface FogClawConfig {
|
|
|
19
29
|
confidence_threshold: number;
|
|
20
30
|
custom_entities: string[];
|
|
21
31
|
entityActions: Record<string, GuardrailAction>;
|
|
32
|
+
entityConfidenceThresholds: EntityConfidenceThresholds;
|
|
33
|
+
allowlist: EntityAllowlist;
|
|
34
|
+
auditEnabled: boolean;
|
|
22
35
|
}
|
|
23
36
|
|
|
24
37
|
export interface ScanResult {
|
|
@@ -32,6 +45,12 @@ export interface RedactResult {
|
|
|
32
45
|
entities: Entity[];
|
|
33
46
|
}
|
|
34
47
|
|
|
48
|
+
export interface GuardrailPlan {
|
|
49
|
+
blocked: Entity[];
|
|
50
|
+
warned: Entity[];
|
|
51
|
+
redacted: Entity[];
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
export const CANONICAL_TYPE_MAP: Record<string, string> = {
|
|
36
55
|
DOB: "DATE",
|
|
37
56
|
ZIP: "ZIP_CODE",
|
|
@@ -50,3 +69,7 @@ export function canonicalType(entityType: string): string {
|
|
|
50
69
|
const normalized = entityType.toUpperCase().trim();
|
|
51
70
|
return CANONICAL_TYPE_MAP[normalized] ?? normalized;
|
|
52
71
|
}
|
|
72
|
+
|
|
73
|
+
export function resolveAction(entity: Entity, config: FogClawConfig): GuardrailAction {
|
|
74
|
+
return config.entityActions[entity.label] ?? config.guardrail_mode;
|
|
75
|
+
}
|
package/tests/config.test.ts
CHANGED
|
@@ -1,104 +1,78 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { loadConfig, DEFAULT_CONFIG } from "../src/config.js";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
it("returns defaults when no overrides are provided", () => {
|
|
6
|
-
const config = loadConfig({});
|
|
7
|
-
expect(config).toEqual(DEFAULT_CONFIG);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it("merges partial overrides with defaults", () => {
|
|
11
|
-
const config = loadConfig({ guardrail_mode: "block", confidence_threshold: 0.8 });
|
|
12
|
-
|
|
13
|
-
expect(config.guardrail_mode).toBe("block");
|
|
14
|
-
expect(config.confidence_threshold).toBe(0.8);
|
|
15
|
-
// Unset defaults are preserved
|
|
16
|
-
expect(config.enabled).toBe(true);
|
|
17
|
-
expect(config.redactStrategy).toBe("token");
|
|
18
|
-
expect(config.model).toBe("onnx-community/gliner_large-v2.1");
|
|
19
|
-
expect(config.custom_entities).toEqual([]);
|
|
20
|
-
expect(config.entityActions).toEqual({});
|
|
21
|
-
});
|
|
3
|
+
import { loadConfig } from "../src/config.js";
|
|
22
4
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
expect(() => loadConfig({ guardrail_mode: "warn" })).not.toThrow();
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("rejects invalid guardrail_mode", () => {
|
|
30
|
-
expect(() =>
|
|
31
|
-
loadConfig({ guardrail_mode: "invalid" as never }),
|
|
32
|
-
).toThrowError(
|
|
33
|
-
'Invalid guardrail_mode "invalid". Must be one of: redact, block, warn',
|
|
34
|
-
);
|
|
35
|
-
});
|
|
5
|
+
describe("FogClaw config", () => {
|
|
6
|
+
it("loads defaults for new policy fields", () => {
|
|
7
|
+
const config = loadConfig({});
|
|
36
8
|
|
|
37
|
-
|
|
38
|
-
expect(
|
|
39
|
-
|
|
40
|
-
|
|
9
|
+
expect(config.entityConfidenceThresholds).toEqual({});
|
|
10
|
+
expect(config.allowlist).toMatchObject({
|
|
11
|
+
values: [],
|
|
12
|
+
patterns: [],
|
|
13
|
+
entities: {},
|
|
14
|
+
});
|
|
41
15
|
});
|
|
42
16
|
|
|
43
|
-
it("
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
);
|
|
49
|
-
});
|
|
17
|
+
it("canonicalizes per-entity confidence threshold keys", () => {
|
|
18
|
+
const config = loadConfig({
|
|
19
|
+
entityConfidenceThresholds: {
|
|
20
|
+
person: 0.7,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
50
23
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
expect(() => loadConfig({ confidence_threshold: 0.5 })).not.toThrow();
|
|
24
|
+
expect(config.entityConfidenceThresholds).toEqual({
|
|
25
|
+
PERSON: 0.7,
|
|
26
|
+
});
|
|
55
27
|
});
|
|
56
28
|
|
|
57
|
-
it("rejects
|
|
29
|
+
it("rejects invalid per-entity confidence thresholds", () => {
|
|
58
30
|
expect(() =>
|
|
59
|
-
loadConfig({
|
|
60
|
-
|
|
31
|
+
loadConfig({
|
|
32
|
+
entityConfidenceThresholds: {
|
|
33
|
+
PERSON: 1.2,
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
).toThrow('entityConfidenceThresholds["PERSON"] must be between 0 and 1, got 1.2');
|
|
61
37
|
});
|
|
62
38
|
|
|
63
|
-
it("
|
|
39
|
+
it("validates allowlist regex patterns", () => {
|
|
64
40
|
expect(() =>
|
|
65
|
-
loadConfig({
|
|
66
|
-
|
|
41
|
+
loadConfig({
|
|
42
|
+
allowlist: {
|
|
43
|
+
values: ["ok@example.com"],
|
|
44
|
+
patterns: ["["],
|
|
45
|
+
entities: {
|
|
46
|
+
PERSON: ["John"],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
).toThrow(/invalid regex pattern/);
|
|
67
51
|
});
|
|
68
52
|
|
|
69
|
-
it("
|
|
53
|
+
it("canonicalizes allowlist entity keys", () => {
|
|
70
54
|
const config = loadConfig({
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
SSN: "warn",
|
|
55
|
+
allowlist: {
|
|
56
|
+
entities: {
|
|
57
|
+
person: ["John"],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
77
60
|
});
|
|
78
|
-
});
|
|
79
61
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
entityActions: { EMAIL: "delete" as never },
|
|
84
|
-
}),
|
|
85
|
-
).toThrowError(
|
|
86
|
-
'Invalid action "delete" for entity type "EMAIL". Must be one of: redact, block, warn',
|
|
87
|
-
);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("preserves custom_entities from overrides", () => {
|
|
91
|
-
const config = loadConfig({ custom_entities: ["EMPLOYEE_ID", "PROJECT_CODE"] });
|
|
92
|
-
expect(config.custom_entities).toEqual(["EMPLOYEE_ID", "PROJECT_CODE"]);
|
|
62
|
+
expect(config.allowlist.entities).toEqual({
|
|
63
|
+
PERSON: ["John"],
|
|
64
|
+
});
|
|
93
65
|
});
|
|
94
66
|
|
|
95
|
-
it("
|
|
96
|
-
const config = loadConfig({
|
|
97
|
-
|
|
98
|
-
|
|
67
|
+
it("canonicalizes entity action labels", () => {
|
|
68
|
+
const config = loadConfig({
|
|
69
|
+
entityActions: {
|
|
70
|
+
person: "block",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
99
73
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
74
|
+
expect(config.entityActions).toEqual({
|
|
75
|
+
PERSON: "block",
|
|
76
|
+
});
|
|
103
77
|
});
|
|
104
78
|
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { extractText, replaceText } from "../src/extract.js";
|
|
3
|
+
|
|
4
|
+
describe("extractText", () => {
|
|
5
|
+
it("extracts from a plain string", () => {
|
|
6
|
+
expect(extractText("hello world")).toBe("hello world");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("extracts from an object with content as string", () => {
|
|
10
|
+
expect(extractText({ role: "toolResult", content: "file contents here" })).toBe(
|
|
11
|
+
"file contents here",
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("extracts from content block array with single text block", () => {
|
|
16
|
+
const msg = {
|
|
17
|
+
role: "toolResult",
|
|
18
|
+
content: [{ type: "text", text: "block one" }],
|
|
19
|
+
};
|
|
20
|
+
expect(extractText(msg)).toBe("block one");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("extracts from content block array with multiple text blocks", () => {
|
|
24
|
+
const msg = {
|
|
25
|
+
content: [
|
|
26
|
+
{ type: "text", text: "first" },
|
|
27
|
+
{ type: "text", text: "second" },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
expect(extractText(msg)).toBe("first\0second");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("skips non-text blocks in content array", () => {
|
|
34
|
+
const msg = {
|
|
35
|
+
content: [
|
|
36
|
+
{ type: "text", text: "visible" },
|
|
37
|
+
{ type: "image", source: { data: "base64..." } },
|
|
38
|
+
{ type: "text", text: "also visible" },
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
expect(extractText(msg)).toBe("visible\0also visible");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns empty string for null", () => {
|
|
45
|
+
expect(extractText(null)).toBe("");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns empty string for undefined", () => {
|
|
49
|
+
expect(extractText(undefined)).toBe("");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns empty string for a number", () => {
|
|
53
|
+
expect(extractText(42)).toBe("");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns empty string for object with no content", () => {
|
|
57
|
+
expect(extractText({ role: "toolResult" })).toBe("");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns empty string for object with null content", () => {
|
|
61
|
+
expect(extractText({ content: null })).toBe("");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns empty string for empty content array", () => {
|
|
65
|
+
expect(extractText({ content: [] })).toBe("");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns empty string for content array with only image blocks", () => {
|
|
69
|
+
const msg = {
|
|
70
|
+
content: [
|
|
71
|
+
{ type: "image", source: { data: "..." } },
|
|
72
|
+
{ type: "image", source: { data: "..." } },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
expect(extractText(msg)).toBe("");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("handles empty string content", () => {
|
|
79
|
+
expect(extractText({ content: "" })).toBe("");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("handles text block with empty text", () => {
|
|
83
|
+
const msg = { content: [{ type: "text", text: "" }] };
|
|
84
|
+
expect(extractText(msg)).toBe("");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("handles content array with mixed valid and invalid blocks", () => {
|
|
88
|
+
const msg = {
|
|
89
|
+
content: [
|
|
90
|
+
{ type: "text", text: "valid" },
|
|
91
|
+
{ type: "text" }, // missing text property
|
|
92
|
+
null,
|
|
93
|
+
{ type: "text", text: "also valid" },
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
expect(extractText(msg)).toBe("valid\0also valid");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("replaceText", () => {
|
|
101
|
+
it("replaces plain string message", () => {
|
|
102
|
+
expect(replaceText("original", "redacted")).toBe("redacted");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("replaces content string in object", () => {
|
|
106
|
+
const msg = { role: "toolResult", content: "original text" };
|
|
107
|
+
const result = replaceText(msg, "redacted text") as Record<string, unknown>;
|
|
108
|
+
expect(result.content).toBe("redacted text");
|
|
109
|
+
expect(result.role).toBe("toolResult");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("does not mutate the original message object", () => {
|
|
113
|
+
const msg = { role: "toolResult", content: "original" };
|
|
114
|
+
replaceText(msg, "redacted");
|
|
115
|
+
expect(msg.content).toBe("original");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("replaces single text block in content array", () => {
|
|
119
|
+
const msg = {
|
|
120
|
+
content: [{ type: "text", text: "original" }],
|
|
121
|
+
};
|
|
122
|
+
const result = replaceText(msg, "redacted") as Record<string, unknown>;
|
|
123
|
+
const content = result.content as Array<Record<string, unknown>>;
|
|
124
|
+
expect(content[0].text).toBe("redacted");
|
|
125
|
+
expect(content[0].type).toBe("text");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("replaces multiple text blocks using segment separator", () => {
|
|
129
|
+
const msg = {
|
|
130
|
+
content: [
|
|
131
|
+
{ type: "text", text: "first original" },
|
|
132
|
+
{ type: "text", text: "second original" },
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
const result = replaceText(msg, "first redacted\0second redacted") as Record<string, unknown>;
|
|
136
|
+
const content = result.content as Array<Record<string, unknown>>;
|
|
137
|
+
expect(content[0].text).toBe("first redacted");
|
|
138
|
+
expect(content[1].text).toBe("second redacted");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("preserves non-text blocks in content array", () => {
|
|
142
|
+
const msg = {
|
|
143
|
+
content: [
|
|
144
|
+
{ type: "text", text: "original" },
|
|
145
|
+
{ type: "image", source: { data: "base64" } },
|
|
146
|
+
{ type: "text", text: "also original" },
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
const result = replaceText(msg, "redacted\0also redacted") as Record<string, unknown>;
|
|
150
|
+
const content = result.content as Array<Record<string, unknown>>;
|
|
151
|
+
expect(content[0].text).toBe("redacted");
|
|
152
|
+
expect((content[1] as any).type).toBe("image");
|
|
153
|
+
expect((content[1] as any).source.data).toBe("base64");
|
|
154
|
+
expect(content[2].text).toBe("also redacted");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("returns null unchanged", () => {
|
|
158
|
+
expect(replaceText(null, "x")).toBe(null);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns undefined unchanged", () => {
|
|
162
|
+
expect(replaceText(undefined, "x")).toBe(undefined);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns number unchanged", () => {
|
|
166
|
+
expect(replaceText(42, "x")).toBe(42);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("returns message unchanged if content is null", () => {
|
|
170
|
+
const msg = { content: null };
|
|
171
|
+
expect(replaceText(msg, "x")).toBe(msg);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns message unchanged if content is not string or array", () => {
|
|
175
|
+
const msg = { content: 123 };
|
|
176
|
+
expect(replaceText(msg, "x")).toBe(msg);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("preserves extra properties on the message", () => {
|
|
180
|
+
const msg = { role: "toolResult", content: "original", toolCallId: "abc123" };
|
|
181
|
+
const result = replaceText(msg, "redacted") as Record<string, unknown>;
|
|
182
|
+
expect(result.toolCallId).toBe("abc123");
|
|
183
|
+
expect(result.role).toBe("toolResult");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { createMessageSendingHandler } from "../src/message-sending-handler.js";
|
|
3
|
+
import { Scanner } from "../src/scanner.js";
|
|
4
|
+
import type { FogClawConfig } from "../src/types.js";
|
|
5
|
+
|
|
6
|
+
function makeConfig(overrides: Partial<FogClawConfig> = {}): FogClawConfig {
|
|
7
|
+
return {
|
|
8
|
+
enabled: true,
|
|
9
|
+
guardrail_mode: "redact",
|
|
10
|
+
redactStrategy: "token",
|
|
11
|
+
model: "invalid:/not/real/model",
|
|
12
|
+
confidence_threshold: 0.5,
|
|
13
|
+
custom_entities: [],
|
|
14
|
+
entityActions: {},
|
|
15
|
+
entityConfidenceThresholds: {},
|
|
16
|
+
allowlist: { values: [], patterns: [], entities: {} },
|
|
17
|
+
auditEnabled: false,
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeLogger() {
|
|
23
|
+
return {
|
|
24
|
+
info: vi.fn(),
|
|
25
|
+
warn: vi.fn(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeCtx(channelId = "telegram") {
|
|
30
|
+
return { channelId };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("createMessageSendingHandler", () => {
|
|
34
|
+
// Suppress GLiNER init warnings
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
37
|
+
});
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns an async function", () => {
|
|
43
|
+
const config = makeConfig();
|
|
44
|
+
const scanner = new Scanner(config);
|
|
45
|
+
const handler = createMessageSendingHandler(config, scanner);
|
|
46
|
+
expect(typeof handler).toBe("function");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("redacts SSN in outbound message", async () => {
|
|
50
|
+
const config = makeConfig();
|
|
51
|
+
const scanner = new Scanner(config);
|
|
52
|
+
const handler = createMessageSendingHandler(config, scanner);
|
|
53
|
+
|
|
54
|
+
const result = await handler(
|
|
55
|
+
{ to: "user123", content: "Your SSN is 123-45-6789." },
|
|
56
|
+
makeCtx(),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(result).toBeDefined();
|
|
60
|
+
expect(result!.content).toContain("[SSN_1]");
|
|
61
|
+
expect(result!.content).not.toContain("123-45-6789");
|
|
62
|
+
expect(result!.cancel).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("redacts email and phone in outbound message", async () => {
|
|
66
|
+
const config = makeConfig();
|
|
67
|
+
const scanner = new Scanner(config);
|
|
68
|
+
const handler = createMessageSendingHandler(config, scanner);
|
|
69
|
+
|
|
70
|
+
const result = await handler(
|
|
71
|
+
{ to: "user", content: "Call 555-123-4567 or email john@example.com" },
|
|
72
|
+
makeCtx(),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(result).toBeDefined();
|
|
76
|
+
expect(result!.content).toContain("[PHONE_1]");
|
|
77
|
+
expect(result!.content).toContain("[EMAIL_1]");
|
|
78
|
+
expect(result!.content).not.toContain("555-123-4567");
|
|
79
|
+
expect(result!.content).not.toContain("john@example.com");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns void when no PII found", async () => {
|
|
83
|
+
const config = makeConfig();
|
|
84
|
+
const scanner = new Scanner(config);
|
|
85
|
+
const handler = createMessageSendingHandler(config, scanner);
|
|
86
|
+
|
|
87
|
+
const result = await handler(
|
|
88
|
+
{ to: "user", content: "Hello, how can I help you today?" },
|
|
89
|
+
makeCtx(),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(result).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns void for empty content", async () => {
|
|
96
|
+
const config = makeConfig();
|
|
97
|
+
const scanner = new Scanner(config);
|
|
98
|
+
const handler = createMessageSendingHandler(config, scanner);
|
|
99
|
+
|
|
100
|
+
const result = await handler(
|
|
101
|
+
{ to: "user", content: "" },
|
|
102
|
+
makeCtx(),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(result).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("never returns cancel: true", async () => {
|
|
109
|
+
const config = makeConfig({
|
|
110
|
+
entityActions: { SSN: "block" },
|
|
111
|
+
});
|
|
112
|
+
const scanner = new Scanner(config);
|
|
113
|
+
const handler = createMessageSendingHandler(config, scanner);
|
|
114
|
+
|
|
115
|
+
const result = await handler(
|
|
116
|
+
{ to: "user", content: "SSN 123-45-6789" },
|
|
117
|
+
makeCtx(),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(result).toBeDefined();
|
|
121
|
+
expect(result!.cancel).toBeUndefined();
|
|
122
|
+
expect(result!.content).toContain("[SSN_1]");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("all guardrail modes produce span-level redaction", async () => {
|
|
126
|
+
const config = makeConfig({
|
|
127
|
+
entityActions: { SSN: "block", EMAIL: "warn", PHONE: "redact" },
|
|
128
|
+
});
|
|
129
|
+
const scanner = new Scanner(config);
|
|
130
|
+
const handler = createMessageSendingHandler(config, scanner);
|
|
131
|
+
|
|
132
|
+
const result = await handler(
|
|
133
|
+
{
|
|
134
|
+
to: "user",
|
|
135
|
+
content: "SSN 123-45-6789, email john@example.com, call 555-123-4567",
|
|
136
|
+
},
|
|
137
|
+
makeCtx(),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(result).toBeDefined();
|
|
141
|
+
expect(result!.content).toContain("[SSN_1]");
|
|
142
|
+
expect(result!.content).toContain("[EMAIL_1]");
|
|
143
|
+
expect(result!.content).toContain("[PHONE_1]");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("respects allowlist — global values", async () => {
|
|
147
|
+
const config = makeConfig({
|
|
148
|
+
allowlist: {
|
|
149
|
+
values: ["noreply@example.com"],
|
|
150
|
+
patterns: [],
|
|
151
|
+
entities: {},
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
const scanner = new Scanner(config);
|
|
155
|
+
const handler = createMessageSendingHandler(config, scanner);
|
|
156
|
+
|
|
157
|
+
const result = await handler(
|
|
158
|
+
{ to: "user", content: "Contact noreply@example.com for help" },
|
|
159
|
+
makeCtx(),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(result).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("uses mask redaction strategy", async () => {
|
|
166
|
+
const config = makeConfig({ redactStrategy: "mask" });
|
|
167
|
+
const scanner = new Scanner(config);
|
|
168
|
+
const handler = createMessageSendingHandler(config, scanner);
|
|
169
|
+
|
|
170
|
+
const result = await handler(
|
|
171
|
+
{ to: "user", content: "SSN is 123-45-6789" },
|
|
172
|
+
makeCtx(),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
expect(result).toBeDefined();
|
|
176
|
+
expect(result!.content).toContain("***********");
|
|
177
|
+
expect(result!.content).not.toContain("123-45-6789");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("uses hash redaction strategy", async () => {
|
|
181
|
+
const config = makeConfig({ redactStrategy: "hash" });
|
|
182
|
+
const scanner = new Scanner(config);
|
|
183
|
+
const handler = createMessageSendingHandler(config, scanner);
|
|
184
|
+
|
|
185
|
+
const result = await handler(
|
|
186
|
+
{ to: "user", content: "SSN is 123-45-6789" },
|
|
187
|
+
makeCtx(),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(result).toBeDefined();
|
|
191
|
+
expect(result!.content).toMatch(/\[SSN_[a-f0-9]{12}\]/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("audit logging", () => {
|
|
195
|
+
it("emits audit log with source outbound when PII found", async () => {
|
|
196
|
+
const config = makeConfig({ auditEnabled: true });
|
|
197
|
+
const scanner = new Scanner(config);
|
|
198
|
+
const logger = makeLogger();
|
|
199
|
+
const handler = createMessageSendingHandler(config, scanner, logger);
|
|
200
|
+
|
|
201
|
+
await handler(
|
|
202
|
+
{ to: "user", content: "SSN 123-45-6789" },
|
|
203
|
+
makeCtx("discord"),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(logger.info).toHaveBeenCalledOnce();
|
|
207
|
+
const logCall = logger.info.mock.calls[0][0] as string;
|
|
208
|
+
expect(logCall).toContain("[FOGCLAW AUDIT]");
|
|
209
|
+
expect(logCall).toContain("outbound_scan");
|
|
210
|
+
expect(logCall).toContain('"source":"outbound"');
|
|
211
|
+
expect(logCall).toContain('"channelId":"discord"');
|
|
212
|
+
expect(logCall).toContain('"SSN"');
|
|
213
|
+
expect(logCall).not.toContain("123-45-6789");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("does not emit audit log when auditEnabled is false", async () => {
|
|
217
|
+
const config = makeConfig({ auditEnabled: false });
|
|
218
|
+
const scanner = new Scanner(config);
|
|
219
|
+
const logger = makeLogger();
|
|
220
|
+
const handler = createMessageSendingHandler(config, scanner, logger);
|
|
221
|
+
|
|
222
|
+
await handler(
|
|
223
|
+
{ to: "user", content: "SSN 123-45-6789" },
|
|
224
|
+
makeCtx(),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(logger.info).not.toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("does not emit audit log when no PII found", async () => {
|
|
231
|
+
const config = makeConfig({ auditEnabled: true });
|
|
232
|
+
const scanner = new Scanner(config);
|
|
233
|
+
const logger = makeLogger();
|
|
234
|
+
const handler = createMessageSendingHandler(config, scanner, logger);
|
|
235
|
+
|
|
236
|
+
await handler(
|
|
237
|
+
{ to: "user", content: "Clean message" },
|
|
238
|
+
makeCtx(),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expect(logger.info).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|