@datafog/fogclaw 0.2.0 → 0.3.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 +11 -0
- package/dist/backlog-tools.d.ts +57 -0
- package/dist/backlog-tools.d.ts.map +1 -0
- package/dist/backlog-tools.js +173 -0
- package/dist/backlog-tools.js.map +1 -0
- package/dist/backlog.d.ts +82 -0
- package/dist/backlog.d.ts.map +1 -0
- package/dist/backlog.js +169 -0
- package/dist/backlog.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +87 -2
- package/dist/index.js.map +1 -1
- package/dist/message-sending-handler.d.ts +2 -1
- package/dist/message-sending-handler.d.ts.map +1 -1
- package/dist/message-sending-handler.js +5 -1
- package/dist/message-sending-handler.js.map +1 -1
- package/dist/tool-result-handler.d.ts +2 -1
- package/dist/tool-result-handler.d.ts.map +1 -1
- package/dist/tool-result-handler.js +5 -1
- package/dist/tool-result-handler.js.map +1 -1
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/openclaw.plugin.json +11 -1
- package/package.json +7 -1
- package/.github/workflows/harness-docs.yml +0 -30
- package/AGENTS.md +0 -28
- package/docs/DATA.md +0 -28
- package/docs/DESIGN.md +0 -17
- package/docs/DOMAIN_DOCS.md +0 -30
- package/docs/FRONTEND.md +0 -24
- package/docs/OBSERVABILITY.md +0 -32
- package/docs/PLANS.md +0 -171
- package/docs/PRODUCT_SENSE.md +0 -20
- package/docs/RELIABILITY.md +0 -60
- package/docs/SECURITY.md +0 -52
- package/docs/design-docs/core-beliefs.md +0 -17
- package/docs/design-docs/index.md +0 -8
- package/docs/generated/README.md +0 -36
- package/docs/generated/memory.md +0 -1
- package/docs/plans/2026-02-16-fogclaw-design.md +0 -172
- package/docs/plans/2026-02-16-fogclaw-implementation.md +0 -1606
- package/docs/plans/README.md +0 -15
- package/docs/plans/active/2026-02-16-feat-openclaw-official-submission-plan.md +0 -386
- package/docs/plans/active/2026-02-17-feat-release-fogclaw-via-datafog-package-plan.md +0 -328
- package/docs/plans/active/2026-02-17-feat-submit-fogclaw-to-openclaw-plan.md +0 -244
- package/docs/plans/active/2026-02-17-feat-tool-result-pii-scanning-plan.md +0 -293
- package/docs/plans/tech-debt-tracker.md +0 -42
- package/docs/plugins/fogclaw.md +0 -101
- package/docs/runbooks/address-review-findings.md +0 -30
- package/docs/runbooks/ci-failures.md +0 -46
- package/docs/runbooks/code-review.md +0 -34
- package/docs/runbooks/merge-change.md +0 -28
- package/docs/runbooks/pull-request.md +0 -45
- package/docs/runbooks/record-evidence.md +0 -43
- package/docs/runbooks/reproduce-bug.md +0 -42
- package/docs/runbooks/respond-to-feedback.md +0 -42
- package/docs/runbooks/review-findings.md +0 -31
- package/docs/runbooks/submit-openclaw-plugin.md +0 -68
- package/docs/runbooks/update-agents-md.md +0 -59
- package/docs/runbooks/update-domain-docs.md +0 -42
- package/docs/runbooks/validate-current-state.md +0 -41
- package/docs/runbooks/verify-release.md +0 -69
- package/docs/specs/2026-02-16-feat-openclaw-official-submission-spec.md +0 -115
- package/docs/specs/2026-02-17-feat-outbound-message-pii-scanning-spec.md +0 -93
- package/docs/specs/2026-02-17-feat-submit-fogclaw-to-openclaw.md +0 -125
- package/docs/specs/2026-02-17-feat-tool-result-pii-scanning-spec.md +0 -122
- package/docs/specs/README.md +0 -5
- package/docs/specs/index.md +0 -8
- package/docs/spikes/README.md +0 -8
- package/fogclaw.config.example.json +0 -33
- package/scripts/ci/he-docs-config.json +0 -123
- package/scripts/ci/he-docs-drift.sh +0 -112
- package/scripts/ci/he-docs-lint.sh +0 -234
- package/scripts/ci/he-plans-lint.sh +0 -354
- package/scripts/ci/he-runbooks-lint.sh +0 -445
- package/scripts/ci/he-specs-lint.sh +0 -258
- package/scripts/ci/he-spikes-lint.sh +0 -249
- package/scripts/runbooks/select-runbooks.sh +0 -154
- package/src/config.ts +0 -183
- package/src/engines/gliner.ts +0 -240
- package/src/engines/regex.ts +0 -71
- package/src/extract.ts +0 -98
- package/src/index.ts +0 -381
- package/src/message-sending-handler.ts +0 -87
- package/src/redactor.ts +0 -51
- package/src/scanner.ts +0 -196
- package/src/tool-result-handler.ts +0 -133
- package/src/types.ts +0 -75
- package/tests/config.test.ts +0 -78
- package/tests/extract.test.ts +0 -185
- package/tests/gliner.test.ts +0 -289
- package/tests/message-sending-handler.test.ts +0 -244
- package/tests/plugin-smoke.test.ts +0 -250
- package/tests/redactor.test.ts +0 -320
- package/tests/regex.test.ts +0 -345
- package/tests/scanner.test.ts +0 -348
- package/tests/tool-result-handler.test.ts +0 -329
- package/tsconfig.json +0 -20
package/tests/gliner.test.ts
DELETED
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
import { beforeAll, beforeEach, afterAll, describe, it, expect, vi } from "vitest";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
// Mock the gliner npm package so we don't need the actual 1.4GB model
|
|
7
|
-
vi.mock("gliner", () => {
|
|
8
|
-
class MockGliner {
|
|
9
|
-
private config: any;
|
|
10
|
-
|
|
11
|
-
constructor(config: any) {
|
|
12
|
-
this.config = config;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
async initialize(): Promise<void> {
|
|
16
|
-
// No-op in mock
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async inference(
|
|
20
|
-
request: { texts: string[]; entities: string[] } | string | string[],
|
|
21
|
-
maybeEntities?: string[],
|
|
22
|
-
_flatNer = false,
|
|
23
|
-
_threshold = 0.5,
|
|
24
|
-
): Promise<Array<{ text: string; label: string; score: number; start: number; end: number }>> {
|
|
25
|
-
const text =
|
|
26
|
-
typeof request === "string"
|
|
27
|
-
? request
|
|
28
|
-
: Array.isArray(request)
|
|
29
|
-
? request[0] ?? ""
|
|
30
|
-
: request.texts[0] ?? "";
|
|
31
|
-
const requestEntities =
|
|
32
|
-
typeof request === "object" && request !== null && "entities" in request
|
|
33
|
-
? request.entities
|
|
34
|
-
: undefined;
|
|
35
|
-
const labels =
|
|
36
|
-
Array.isArray(maybeEntities)
|
|
37
|
-
? maybeEntities
|
|
38
|
-
: requestEntities ?? [];
|
|
39
|
-
const results: Array<{ text: string; label: string; score: number; start: number; end: number }> = [];
|
|
40
|
-
|
|
41
|
-
// Simulate entity detection for "John Smith"
|
|
42
|
-
const johnIndex = text.indexOf("John Smith");
|
|
43
|
-
if (johnIndex !== -1 && labels.includes("person")) {
|
|
44
|
-
results.push({
|
|
45
|
-
text: "John Smith",
|
|
46
|
-
label: "person",
|
|
47
|
-
score: 0.95,
|
|
48
|
-
start: johnIndex,
|
|
49
|
-
end: johnIndex + "John Smith".length,
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Simulate entity detection for "Acme Corp"
|
|
54
|
-
const acmeIndex = text.indexOf("Acme Corp");
|
|
55
|
-
if (acmeIndex !== -1 && labels.includes("organization")) {
|
|
56
|
-
results.push({
|
|
57
|
-
text: "Acme Corp",
|
|
58
|
-
label: "organization",
|
|
59
|
-
score: 0.88,
|
|
60
|
-
start: acmeIndex,
|
|
61
|
-
end: acmeIndex + "Acme Corp".length,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Simulate entity detection for "New York"
|
|
66
|
-
const nyIndex = text.indexOf("New York");
|
|
67
|
-
if (nyIndex !== -1 && labels.includes("location")) {
|
|
68
|
-
results.push({
|
|
69
|
-
text: "New York",
|
|
70
|
-
label: "location",
|
|
71
|
-
score: 0.91,
|
|
72
|
-
start: nyIndex,
|
|
73
|
-
end: nyIndex + "New York".length,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return results;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return { Gliner: MockGliner };
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
vi.mock("gliner/node", () => {
|
|
85
|
-
class MockGliner {
|
|
86
|
-
private config: any;
|
|
87
|
-
|
|
88
|
-
constructor(config: any) {
|
|
89
|
-
this.config = config;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async initialize(): Promise<void> {
|
|
93
|
-
// No-op in mock
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async inference(
|
|
97
|
-
request: { texts: string[]; entities: string[] } | string | string[],
|
|
98
|
-
maybeEntities?: string[],
|
|
99
|
-
_flatNer = false,
|
|
100
|
-
_threshold = 0.5,
|
|
101
|
-
): Promise<Array<{ text: string; label: string; score: number; start: number; end: number }>> {
|
|
102
|
-
const text =
|
|
103
|
-
typeof request === "string"
|
|
104
|
-
? request
|
|
105
|
-
: Array.isArray(request)
|
|
106
|
-
? request[0] ?? ""
|
|
107
|
-
: request.texts[0] ?? "";
|
|
108
|
-
const requestEntities =
|
|
109
|
-
typeof request === "object" && request !== null && "entities" in request
|
|
110
|
-
? request.entities
|
|
111
|
-
: undefined;
|
|
112
|
-
const labels =
|
|
113
|
-
Array.isArray(maybeEntities)
|
|
114
|
-
? maybeEntities
|
|
115
|
-
: requestEntities ?? [];
|
|
116
|
-
const results: Array<{ text: string; label: string; score: number; start: number; end: number }> = [];
|
|
117
|
-
|
|
118
|
-
// Simulate entity detection for "John Smith"
|
|
119
|
-
const johnIndex = text.indexOf("John Smith");
|
|
120
|
-
if (johnIndex !== -1 && labels.includes("person")) {
|
|
121
|
-
results.push({
|
|
122
|
-
text: "John Smith",
|
|
123
|
-
label: "person",
|
|
124
|
-
score: 0.95,
|
|
125
|
-
start: johnIndex,
|
|
126
|
-
end: johnIndex + "John Smith".length,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Simulate entity detection for "Acme Corp"
|
|
131
|
-
const acmeIndex = text.indexOf("Acme Corp");
|
|
132
|
-
if (acmeIndex !== -1 && labels.includes("organization")) {
|
|
133
|
-
results.push({
|
|
134
|
-
text: "Acme Corp",
|
|
135
|
-
label: "organization",
|
|
136
|
-
score: 0.88,
|
|
137
|
-
start: acmeIndex,
|
|
138
|
-
end: acmeIndex + "Acme Corp".length,
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Simulate entity detection for "New York"
|
|
143
|
-
const nyIndex = text.indexOf("New York");
|
|
144
|
-
if (nyIndex !== -1 && labels.includes("location")) {
|
|
145
|
-
results.push({
|
|
146
|
-
text: "New York",
|
|
147
|
-
label: "location",
|
|
148
|
-
score: 0.91,
|
|
149
|
-
start: nyIndex,
|
|
150
|
-
end: nyIndex + "New York".length,
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return results;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return { Gliner: MockGliner };
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
import { GlinerEngine } from "../src/engines/gliner.js";
|
|
162
|
-
|
|
163
|
-
const TEST_ONNX_MODEL_PATH = path.join(os.tmpdir(), "fogclaw-gliner-model-test.onnx");
|
|
164
|
-
|
|
165
|
-
beforeAll(async () => {
|
|
166
|
-
await fs.writeFile(TEST_ONNX_MODEL_PATH, "mock-onnx-model", "utf8");
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
afterAll(async () => {
|
|
170
|
-
await fs.unlink(TEST_ONNX_MODEL_PATH).catch(() => undefined);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
describe("GlinerEngine", () => {
|
|
174
|
-
let engine: GlinerEngine;
|
|
175
|
-
|
|
176
|
-
beforeEach(async () => {
|
|
177
|
-
engine = new GlinerEngine(TEST_ONNX_MODEL_PATH, 0.5);
|
|
178
|
-
await engine.initialize();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("detects person entities with canonical PERSON label", async () => {
|
|
182
|
-
const entities = await engine.scan("My name is John Smith and I live here.");
|
|
183
|
-
|
|
184
|
-
expect(entities).toHaveLength(1);
|
|
185
|
-
expect(entities[0].text).toBe("John Smith");
|
|
186
|
-
expect(entities[0].label).toBe("PERSON");
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("detects organization entities with canonical ORGANIZATION label", async () => {
|
|
190
|
-
const entities = await engine.scan("I work at Acme Corp downtown.");
|
|
191
|
-
|
|
192
|
-
expect(entities).toHaveLength(1);
|
|
193
|
-
expect(entities[0].text).toBe("Acme Corp");
|
|
194
|
-
expect(entities[0].label).toBe("ORGANIZATION");
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it("detects multiple entity types in the same text", async () => {
|
|
198
|
-
const entities = await engine.scan(
|
|
199
|
-
"John Smith works at Acme Corp in New York.",
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
expect(entities).toHaveLength(3);
|
|
203
|
-
|
|
204
|
-
const labels = entities.map((e) => e.label);
|
|
205
|
-
expect(labels).toContain("PERSON");
|
|
206
|
-
expect(labels).toContain("ORGANIZATION");
|
|
207
|
-
expect(labels).toContain("LOCATION");
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("returns empty array for text with no entities", async () => {
|
|
211
|
-
const entities = await engine.scan("Hello world, this is a test.");
|
|
212
|
-
|
|
213
|
-
expect(entities).toEqual([]);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it("returns empty array for empty string input", async () => {
|
|
217
|
-
const entities = await engine.scan("");
|
|
218
|
-
|
|
219
|
-
expect(entities).toEqual([]);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("allows setting custom labels without crashing", async () => {
|
|
223
|
-
expect(() => engine.setCustomLabels(["product", "event"])).not.toThrow();
|
|
224
|
-
|
|
225
|
-
// Scan still works after setting custom labels
|
|
226
|
-
const entities = await engine.scan("John Smith attended the event.");
|
|
227
|
-
expect(entities).toHaveLength(1);
|
|
228
|
-
expect(entities[0].label).toBe("PERSON");
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("applies canonical type mapping (lowercase person -> PERSON)", async () => {
|
|
232
|
-
// The mock returns lowercase "person" as label; canonicalType should map it to "PERSON"
|
|
233
|
-
const entities = await engine.scan("John Smith is here.");
|
|
234
|
-
|
|
235
|
-
expect(entities[0].label).toBe("PERSON");
|
|
236
|
-
// Verify it's not lowercase
|
|
237
|
-
expect(entities[0].label).not.toBe("person");
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it("sets source to gliner for all detected entities", async () => {
|
|
241
|
-
const entities = await engine.scan(
|
|
242
|
-
"John Smith works at Acme Corp in New York.",
|
|
243
|
-
);
|
|
244
|
-
|
|
245
|
-
for (const entity of entities) {
|
|
246
|
-
expect(entity.source).toBe("gliner");
|
|
247
|
-
}
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it("confidence comes from model score", async () => {
|
|
251
|
-
const entities = await engine.scan(
|
|
252
|
-
"John Smith works at Acme Corp in New York.",
|
|
253
|
-
);
|
|
254
|
-
|
|
255
|
-
const person = entities.find((e) => e.label === "PERSON");
|
|
256
|
-
const org = entities.find((e) => e.label === "ORGANIZATION");
|
|
257
|
-
const loc = entities.find((e) => e.label === "LOCATION");
|
|
258
|
-
|
|
259
|
-
// These match the scores set in our mock
|
|
260
|
-
expect(person?.confidence).toBe(0.95);
|
|
261
|
-
expect(org?.confidence).toBe(0.88);
|
|
262
|
-
expect(loc?.confidence).toBe(0.91);
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
it("throws if scan is called before initialize", async () => {
|
|
266
|
-
const uninitializedEngine = new GlinerEngine("some-model", 0.5);
|
|
267
|
-
|
|
268
|
-
await expect(uninitializedEngine.scan("test")).rejects.toThrow(
|
|
269
|
-
"GLiNER engine not initialized. Call initialize() first.",
|
|
270
|
-
);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it("reports isInitialized correctly", async () => {
|
|
274
|
-
const freshEngine = new GlinerEngine(TEST_ONNX_MODEL_PATH, 0.5);
|
|
275
|
-
expect(freshEngine.isInitialized).toBe(false);
|
|
276
|
-
|
|
277
|
-
await freshEngine.initialize();
|
|
278
|
-
expect(freshEngine.isInitialized).toBe(true);
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it("includes correct start and end offsets", async () => {
|
|
282
|
-
const text = "Contact John Smith for details.";
|
|
283
|
-
const entities = await engine.scan(text);
|
|
284
|
-
|
|
285
|
-
expect(entities).toHaveLength(1);
|
|
286
|
-
expect(entities[0].start).toBe(8); // "Contact " is 8 chars
|
|
287
|
-
expect(entities[0].end).toBe(18); // 8 + "John Smith".length = 18
|
|
288
|
-
});
|
|
289
|
-
});
|
|
@@ -1,244 +0,0 @@
|
|
|
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
|
-
});
|