@datafog/fogclaw 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.
Files changed (97) hide show
  1. package/.github/workflows/harness-docs.yml +30 -0
  2. package/AGENTS.md +28 -0
  3. package/LICENSE +21 -0
  4. package/README.md +208 -0
  5. package/dist/config.d.ts +4 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +30 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/engines/gliner.d.ts +14 -0
  10. package/dist/engines/gliner.d.ts.map +1 -0
  11. package/dist/engines/gliner.js +75 -0
  12. package/dist/engines/gliner.js.map +1 -0
  13. package/dist/engines/regex.d.ts +5 -0
  14. package/dist/engines/regex.d.ts.map +1 -0
  15. package/dist/engines/regex.js +54 -0
  16. package/dist/engines/regex.js.map +1 -0
  17. package/dist/index.d.ts +19 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +157 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/redactor.d.ts +3 -0
  22. package/dist/redactor.d.ts.map +1 -0
  23. package/dist/redactor.js +37 -0
  24. package/dist/redactor.js.map +1 -0
  25. package/dist/scanner.d.ts +11 -0
  26. package/dist/scanner.d.ts.map +1 -0
  27. package/dist/scanner.js +77 -0
  28. package/dist/scanner.js.map +1 -0
  29. package/dist/types.d.ts +31 -0
  30. package/dist/types.d.ts.map +1 -0
  31. package/dist/types.js +18 -0
  32. package/dist/types.js.map +1 -0
  33. package/docs/DATA.md +28 -0
  34. package/docs/DESIGN.md +17 -0
  35. package/docs/DOMAIN_DOCS.md +30 -0
  36. package/docs/FRONTEND.md +24 -0
  37. package/docs/OBSERVABILITY.md +25 -0
  38. package/docs/PLANS.md +171 -0
  39. package/docs/PRODUCT_SENSE.md +20 -0
  40. package/docs/RELIABILITY.md +60 -0
  41. package/docs/SECURITY.md +50 -0
  42. package/docs/design-docs/core-beliefs.md +17 -0
  43. package/docs/design-docs/index.md +8 -0
  44. package/docs/generated/README.md +36 -0
  45. package/docs/generated/memory.md +1 -0
  46. package/docs/plans/2026-02-16-fogclaw-design.md +172 -0
  47. package/docs/plans/2026-02-16-fogclaw-implementation.md +1606 -0
  48. package/docs/plans/README.md +15 -0
  49. package/docs/plans/active/2026-02-16-feat-openclaw-official-submission-plan.md +386 -0
  50. package/docs/plans/active/2026-02-17-feat-release-fogclaw-via-datafog-package-plan.md +318 -0
  51. package/docs/plans/active/2026-02-17-feat-submit-fogclaw-to-openclaw-plan.md +244 -0
  52. package/docs/plans/tech-debt-tracker.md +42 -0
  53. package/docs/plugins/fogclaw.md +95 -0
  54. package/docs/runbooks/address-review-findings.md +30 -0
  55. package/docs/runbooks/ci-failures.md +46 -0
  56. package/docs/runbooks/code-review.md +34 -0
  57. package/docs/runbooks/merge-change.md +28 -0
  58. package/docs/runbooks/pull-request.md +45 -0
  59. package/docs/runbooks/record-evidence.md +43 -0
  60. package/docs/runbooks/reproduce-bug.md +42 -0
  61. package/docs/runbooks/respond-to-feedback.md +42 -0
  62. package/docs/runbooks/review-findings.md +31 -0
  63. package/docs/runbooks/submit-openclaw-plugin.md +68 -0
  64. package/docs/runbooks/update-agents-md.md +59 -0
  65. package/docs/runbooks/update-domain-docs.md +42 -0
  66. package/docs/runbooks/validate-current-state.md +41 -0
  67. package/docs/runbooks/verify-release.md +69 -0
  68. package/docs/specs/2026-02-16-feat-openclaw-official-submission-spec.md +115 -0
  69. package/docs/specs/2026-02-17-feat-submit-fogclaw-to-openclaw.md +125 -0
  70. package/docs/specs/README.md +5 -0
  71. package/docs/specs/index.md +8 -0
  72. package/docs/spikes/README.md +8 -0
  73. package/fogclaw.config.example.json +15 -0
  74. package/openclaw.plugin.json +45 -0
  75. package/package.json +37 -0
  76. package/scripts/ci/he-docs-config.json +123 -0
  77. package/scripts/ci/he-docs-drift.sh +112 -0
  78. package/scripts/ci/he-docs-lint.sh +234 -0
  79. package/scripts/ci/he-plans-lint.sh +354 -0
  80. package/scripts/ci/he-runbooks-lint.sh +445 -0
  81. package/scripts/ci/he-specs-lint.sh +258 -0
  82. package/scripts/ci/he-spikes-lint.sh +249 -0
  83. package/scripts/runbooks/select-runbooks.sh +154 -0
  84. package/src/config.ts +46 -0
  85. package/src/engines/gliner.ts +88 -0
  86. package/src/engines/regex.ts +71 -0
  87. package/src/index.ts +223 -0
  88. package/src/redactor.ts +51 -0
  89. package/src/scanner.ts +90 -0
  90. package/src/types.ts +52 -0
  91. package/tests/config.test.ts +104 -0
  92. package/tests/gliner.test.ts +184 -0
  93. package/tests/plugin-smoke.test.ts +114 -0
  94. package/tests/redactor.test.ts +320 -0
  95. package/tests/regex.test.ts +345 -0
  96. package/tests/scanner.test.ts +199 -0
  97. package/tsconfig.json +20 -0
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from "vitest";
2
+
3
+ import plugin from "../src/index.js";
4
+
5
+ function createApi() {
6
+ const hooks: Array<{ event: string; handler: (event: any) => Promise<any> }> = [];
7
+ const tools: any[] = [];
8
+
9
+ return {
10
+ pluginConfig: {
11
+ model: "invalid:/not/real/model",
12
+ },
13
+ hooks,
14
+ tools,
15
+ logger: {
16
+ info: vi.fn(),
17
+ warn: vi.fn(),
18
+ },
19
+ on: vi.fn((event: string, handler: (event: any) => Promise<any>) => {
20
+ hooks.push({ event, handler });
21
+ }),
22
+ registerTool: vi.fn((tool: any) => {
23
+ tools.push(tool);
24
+ }),
25
+ };
26
+ }
27
+
28
+ describe("FogClaw OpenClaw plugin contract (integration path)", () => {
29
+ beforeAll(() => {
30
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
31
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
32
+ });
33
+
34
+ afterAll(() => {
35
+ vi.restoreAllMocks();
36
+ });
37
+
38
+ beforeEach(() => {
39
+ vi.clearAllMocks();
40
+ });
41
+
42
+ it("registers plugin, hook, and tools", async () => {
43
+ const api = createApi();
44
+
45
+ plugin.register(api);
46
+
47
+ expect(typeof plugin.register).toBe("function");
48
+ expect(api.on).toHaveBeenCalledWith("before_agent_start", expect.any(Function));
49
+ expect(api.registerTool).toHaveBeenCalledTimes(2);
50
+
51
+ const scanTool = api.tools.find((tool: any) => tool.id === "fogclaw_scan");
52
+ const redactTool = api.tools.find((tool: any) => tool.id === "fogclaw_redact");
53
+
54
+ expect(scanTool).toBeDefined();
55
+ expect(redactTool).toBeDefined();
56
+ expect(scanTool.schema.required).toContain("text");
57
+ expect(redactTool.schema.required).toContain("text");
58
+ });
59
+
60
+ it("validates hook and tool behavior against real Scanner execution path", async () => {
61
+ const api = createApi();
62
+
63
+ plugin.register(api);
64
+
65
+ const hook = api.hooks.find((entry: any) => entry.event === "before_agent_start");
66
+ expect(hook).toBeDefined();
67
+
68
+ const hookResult = await hook!.handler({
69
+ prompt: "Email me at john@example.com today.",
70
+ });
71
+
72
+ expect(hookResult).toBeDefined();
73
+ expect(hookResult?.prependContext).toContain("[FOGCLAW REDACTED]");
74
+ expect(hookResult?.prependContext).not.toContain("john@example.com");
75
+
76
+ const scanTool = api.tools.find((tool: any) => tool.id === "fogclaw_scan");
77
+ const scanOutput = await scanTool.handler({
78
+ text: "Email me at john@example.com today.",
79
+ });
80
+
81
+ expect(scanOutput.content?.[0]?.type).toBe("text");
82
+
83
+ const scanParsed = JSON.parse(scanOutput.content[0].text);
84
+ expect(Array.isArray(scanParsed.entities)).toBe(true);
85
+ expect(scanParsed.count).toBe(scanParsed.entities.length);
86
+ expect(scanParsed.entities[0].label).toBe("EMAIL");
87
+
88
+ const redactTool = api.tools.find((tool: any) => tool.id === "fogclaw_redact");
89
+ const redactOutput = await redactTool.handler({
90
+ text: "Email me at john@example.com today.",
91
+ strategy: "token",
92
+ });
93
+
94
+ const redactParsed = JSON.parse(redactOutput.content[0].text);
95
+ expect(redactParsed.redacted_text).toContain("[EMAIL_");
96
+ expect(redactParsed.redacted_text).not.toContain("john@example.com");
97
+ });
98
+
99
+ it("passes custom_labels through tool path in real execution", async () => {
100
+ const api = createApi();
101
+
102
+ plugin.register(api);
103
+ const scanTool = api.tools.find((tool: any) => tool.id === "fogclaw_scan");
104
+
105
+ const scanOutput = await scanTool.handler({
106
+ text: "Confidential note for Acme project roadmap",
107
+ custom_labels: ["project", "competitor name"],
108
+ });
109
+
110
+ const parsed = JSON.parse(scanOutput.content[0].text);
111
+ expect(parsed.count).toBe(parsed.entities.length);
112
+ expect(parsed.entities).toEqual(expect.any(Array));
113
+ });
114
+ });
@@ -0,0 +1,320 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createHash } from "node:crypto";
3
+ import { redact } from "../src/redactor.js";
4
+ import type { Entity } from "../src/types.js";
5
+
6
+ // Helper to build Entity objects concisely
7
+ function entity(
8
+ text: string,
9
+ label: string,
10
+ start: number,
11
+ end: number,
12
+ source: "regex" | "gliner" = "regex",
13
+ confidence = 1.0,
14
+ ): Entity {
15
+ return { text, label, start, end, confidence, source };
16
+ }
17
+
18
+ describe("redact", () => {
19
+ // ── token strategy ──────────────────────────────────────────────
20
+
21
+ describe("token strategy", () => {
22
+ it("replaces a single EMAIL entity with [EMAIL_1]", () => {
23
+ const text = "Contact john@example.com for info.";
24
+ const entities: Entity[] = [
25
+ entity("john@example.com", "EMAIL", 8, 24),
26
+ ];
27
+
28
+ const result = redact(text, entities, "token");
29
+
30
+ expect(result.redacted_text).toBe("Contact [EMAIL_1] for info.");
31
+ });
32
+
33
+ it("replaces a single PHONE entity with [PHONE_1]", () => {
34
+ const text = "Call 555-123-4567 now.";
35
+ const entities: Entity[] = [
36
+ entity("555-123-4567", "PHONE", 5, 17),
37
+ ];
38
+
39
+ const result = redact(text, entities, "token");
40
+
41
+ expect(result.redacted_text).toBe("Call [PHONE_1] now.");
42
+ });
43
+
44
+ it("increments counter for multiple entities of the same type", () => {
45
+ const text = "Email alice@a.com and bob@b.com please.";
46
+ const entities: Entity[] = [
47
+ entity("alice@a.com", "EMAIL", 6, 17),
48
+ entity("bob@b.com", "EMAIL", 22, 31),
49
+ ];
50
+
51
+ const result = redact(text, entities, "token");
52
+
53
+ // Processing order is descending by start position, so bob@b.com
54
+ // (start=22) gets [EMAIL_1] and alice@a.com (start=6) gets [EMAIL_2]
55
+ expect(result.redacted_text).toBe(
56
+ "Email [EMAIL_2] and [EMAIL_1] please.",
57
+ );
58
+ expect(result.mapping["[EMAIL_1]"]).toBe("bob@b.com");
59
+ expect(result.mapping["[EMAIL_2]"]).toBe("alice@a.com");
60
+ });
61
+
62
+ it("uses separate counters for different entity types", () => {
63
+ const text = "Email john@example.com or call 555-0000.";
64
+ const entities: Entity[] = [
65
+ entity("john@example.com", "EMAIL", 6, 22),
66
+ entity("555-0000", "PHONE", 31, 39),
67
+ ];
68
+
69
+ const result = redact(text, entities, "token");
70
+
71
+ expect(result.redacted_text).toBe(
72
+ "Email [EMAIL_1] or call [PHONE_1].",
73
+ );
74
+ });
75
+
76
+ it("defaults to token strategy when none is specified", () => {
77
+ const text = "Hi john@example.com";
78
+ const entities: Entity[] = [
79
+ entity("john@example.com", "EMAIL", 3, 19),
80
+ ];
81
+
82
+ const result = redact(text, entities);
83
+
84
+ expect(result.redacted_text).toBe("Hi [EMAIL_1]");
85
+ });
86
+ });
87
+
88
+ // ── mask strategy ───────────────────────────────────────────────
89
+
90
+ describe("mask strategy", () => {
91
+ it("replaces entity with asterisks matching original length", () => {
92
+ const text = "Contact john@example.com for info.";
93
+ // ^ ^
94
+ // 8 24 (16 chars)
95
+ const entities: Entity[] = [
96
+ entity("john@example.com", "EMAIL", 8, 24),
97
+ ];
98
+
99
+ const result = redact(text, entities, "mask");
100
+
101
+ expect(result.redacted_text).toBe("Contact **************** for info.");
102
+ // "john@example.com" is 16 chars -> 16 asterisks
103
+ expect(result.redacted_text.slice(8, 24)).toBe("*".repeat(16));
104
+ });
105
+
106
+ it("uses at least one asterisk for empty-text entity", () => {
107
+ const text = "A B";
108
+ const entities: Entity[] = [
109
+ entity("", "UNKNOWN", 1, 1),
110
+ ];
111
+
112
+ const result = redact(text, entities, "mask");
113
+
114
+ expect(result.redacted_text).toBe("A* B");
115
+ });
116
+
117
+ it("masks multiple entities independently", () => {
118
+ const text = "Name: Alice, Phone: 12345";
119
+ const entities: Entity[] = [
120
+ entity("Alice", "PERSON", 6, 11),
121
+ entity("12345", "PHONE", 20, 25),
122
+ ];
123
+
124
+ const result = redact(text, entities, "mask");
125
+
126
+ expect(result.redacted_text).toBe("Name: *****, Phone: *****");
127
+ });
128
+ });
129
+
130
+ // ── hash strategy ───────────────────────────────────────────────
131
+
132
+ describe("hash strategy", () => {
133
+ it("replaces entity with [LABEL_sha256prefix]", () => {
134
+ const text = "Contact john@example.com for info.";
135
+ const entities: Entity[] = [
136
+ entity("john@example.com", "EMAIL", 8, 24),
137
+ ];
138
+
139
+ const expectedDigest = createHash("sha256")
140
+ .update("john@example.com")
141
+ .digest("hex")
142
+ .slice(0, 12);
143
+
144
+ const result = redact(text, entities, "hash");
145
+
146
+ expect(result.redacted_text).toBe(
147
+ `Contact [EMAIL_${expectedDigest}] for info.`,
148
+ );
149
+ });
150
+
151
+ it("produces consistent hashes across calls", () => {
152
+ const text = "Hi john@example.com";
153
+ const entities: Entity[] = [
154
+ entity("john@example.com", "EMAIL", 3, 19),
155
+ ];
156
+
157
+ const r1 = redact(text, entities, "hash");
158
+ const r2 = redact(text, entities, "hash");
159
+
160
+ expect(r1.redacted_text).toBe(r2.redacted_text);
161
+ });
162
+
163
+ it("produces different hashes for different entity text", () => {
164
+ const text1 = "Hi alice@a.com";
165
+ const text2 = "Hi bobby@b.com";
166
+ const e1: Entity[] = [entity("alice@a.com", "EMAIL", 3, 14)];
167
+ const e2: Entity[] = [entity("bobby@b.com", "EMAIL", 3, 14)];
168
+
169
+ const r1 = redact(text1, e1, "hash");
170
+ const r2 = redact(text2, e2, "hash");
171
+
172
+ expect(r1.redacted_text).not.toBe(r2.redacted_text);
173
+ });
174
+ });
175
+
176
+ // ── mapping ─────────────────────────────────────────────────────
177
+
178
+ describe("mapping", () => {
179
+ it("maps replacement tokens back to original text (token strategy)", () => {
180
+ const text = "Email john@example.com or call 555-0000.";
181
+ const entities: Entity[] = [
182
+ entity("john@example.com", "EMAIL", 6, 22),
183
+ entity("555-0000", "PHONE", 31, 39),
184
+ ];
185
+
186
+ const result = redact(text, entities, "token");
187
+
188
+ expect(result.mapping).toEqual({
189
+ "[EMAIL_1]": "john@example.com",
190
+ "[PHONE_1]": "555-0000",
191
+ });
192
+ });
193
+
194
+ it("maps replacement masks back to original text (mask strategy)", () => {
195
+ const text = "Call 555-0000 now.";
196
+ const entities: Entity[] = [
197
+ entity("555-0000", "PHONE", 5, 13),
198
+ ];
199
+
200
+ const result = redact(text, entities, "mask");
201
+
202
+ expect(result.mapping["********"]).toBe("555-0000");
203
+ });
204
+
205
+ it("maps replacement hashes back to original text (hash strategy)", () => {
206
+ const text = "Hi Alice";
207
+ const entities: Entity[] = [
208
+ entity("Alice", "PERSON", 3, 8),
209
+ ];
210
+
211
+ const result = redact(text, entities, "hash");
212
+
213
+ const digest = createHash("sha256")
214
+ .update("Alice")
215
+ .digest("hex")
216
+ .slice(0, 12);
217
+ expect(result.mapping[`[PERSON_${digest}]`]).toBe("Alice");
218
+ });
219
+ });
220
+
221
+ // ── empty entities ──────────────────────────────────────────────
222
+
223
+ describe("empty entities", () => {
224
+ it("returns original text unchanged when entities array is empty", () => {
225
+ const text = "Nothing to redact here.";
226
+
227
+ const result = redact(text, []);
228
+
229
+ expect(result.redacted_text).toBe(text);
230
+ expect(result.mapping).toEqual({});
231
+ expect(result.entities).toEqual([]);
232
+ });
233
+
234
+ it("returns original text with all strategies when no entities", () => {
235
+ const text = "Still nothing.";
236
+
237
+ for (const strategy of ["token", "mask", "hash"] as const) {
238
+ const result = redact(text, [], strategy);
239
+ expect(result.redacted_text).toBe(text);
240
+ expect(result.mapping).toEqual({});
241
+ }
242
+ });
243
+ });
244
+
245
+ // ── entity ordering / offset integrity ──────────────────────────
246
+
247
+ describe("entity ordering", () => {
248
+ it("handles entities given in reverse order without offset corruption", () => {
249
+ const text = "Name: Alice, Email: alice@a.com";
250
+ const entities: Entity[] = [
251
+ // Provided in reverse order (end of string first)
252
+ entity("alice@a.com", "EMAIL", 20, 31),
253
+ entity("Alice", "PERSON", 6, 11),
254
+ ];
255
+
256
+ const result = redact(text, entities, "token");
257
+
258
+ expect(result.redacted_text).toBe(
259
+ "Name: [PERSON_1], Email: [EMAIL_1]",
260
+ );
261
+ });
262
+
263
+ it("handles entities given in forward order without offset corruption", () => {
264
+ const text = "Name: Alice, Email: alice@a.com";
265
+ const entities: Entity[] = [
266
+ entity("Alice", "PERSON", 6, 11),
267
+ entity("alice@a.com", "EMAIL", 20, 31),
268
+ ];
269
+
270
+ const result = redact(text, entities, "token");
271
+
272
+ expect(result.redacted_text).toBe(
273
+ "Name: [PERSON_1], Email: [EMAIL_1]",
274
+ );
275
+ });
276
+
277
+ it("handles three entities in random order", () => {
278
+ const text = "A: Alice B: bob@b.com C: 555-0000";
279
+ const entities: Entity[] = [
280
+ entity("bob@b.com", "EMAIL", 12, 21),
281
+ entity("555-0000", "PHONE", 25, 33),
282
+ entity("Alice", "PERSON", 3, 8),
283
+ ];
284
+
285
+ const result = redact(text, entities, "token");
286
+
287
+ expect(result.redacted_text).toBe(
288
+ "A: [PERSON_1] B: [EMAIL_1] C: [PHONE_1]",
289
+ );
290
+ });
291
+
292
+ it("does not mutate the original entities array", () => {
293
+ const text = "Name: Alice, Email: alice@a.com";
294
+ const entities: Entity[] = [
295
+ entity("alice@a.com", "EMAIL", 20, 31),
296
+ entity("Alice", "PERSON", 6, 11),
297
+ ];
298
+ const originalOrder = [...entities];
299
+
300
+ redact(text, entities, "token");
301
+
302
+ expect(entities).toEqual(originalOrder);
303
+ });
304
+ });
305
+
306
+ // ── returned entities ───────────────────────────────────────────
307
+
308
+ describe("returned entities", () => {
309
+ it("returns the original entities array in the result", () => {
310
+ const text = "Hi john@example.com";
311
+ const entities: Entity[] = [
312
+ entity("john@example.com", "EMAIL", 3, 19),
313
+ ];
314
+
315
+ const result = redact(text, entities, "token");
316
+
317
+ expect(result.entities).toBe(entities);
318
+ });
319
+ });
320
+ });