@datafog/fogclaw 0.1.6 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +39 -0
  3. package/dist/extract.d.ts +28 -0
  4. package/dist/extract.d.ts.map +1 -0
  5. package/dist/extract.js +91 -0
  6. package/dist/extract.js.map +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +11 -3
  9. package/dist/index.js.map +1 -1
  10. package/dist/message-sending-handler.d.ts +40 -0
  11. package/dist/message-sending-handler.d.ts.map +1 -0
  12. package/dist/message-sending-handler.js +50 -0
  13. package/dist/message-sending-handler.js.map +1 -0
  14. package/dist/tool-result-handler.d.ts +36 -0
  15. package/dist/tool-result-handler.d.ts.map +1 -0
  16. package/dist/tool-result-handler.js +91 -0
  17. package/dist/tool-result-handler.js.map +1 -0
  18. package/dist/types.d.ts +1 -0
  19. package/dist/types.d.ts.map +1 -1
  20. package/dist/types.js +3 -0
  21. package/dist/types.js.map +1 -1
  22. package/docs/OBSERVABILITY.md +22 -15
  23. package/docs/SECURITY.md +6 -4
  24. package/docs/plans/active/2026-02-17-feat-tool-result-pii-scanning-plan.md +293 -0
  25. package/docs/specs/2026-02-17-feat-outbound-message-pii-scanning-spec.md +93 -0
  26. package/docs/specs/2026-02-17-feat-tool-result-pii-scanning-spec.md +122 -0
  27. package/openclaw.plugin.json +1 -1
  28. package/package.json +1 -1
  29. package/src/extract.ts +98 -0
  30. package/src/index.ts +13 -4
  31. package/src/message-sending-handler.ts +87 -0
  32. package/src/tool-result-handler.ts +133 -0
  33. package/src/types.ts +4 -0
  34. package/tests/extract.test.ts +185 -0
  35. package/tests/message-sending-handler.test.ts +244 -0
  36. package/tests/plugin-smoke.test.ts +109 -2
  37. package/tests/tool-result-handler.test.ts +329 -0
package/docs/SECURITY.md CHANGED
@@ -5,11 +5,13 @@ use_when: "Capturing security expectations for this repo: threat model, auth/aut
5
5
 
6
6
  ## Threat Model
7
7
 
8
- FogClaw processes user-provided prompt text and returns extracted entities with character offsets. The main risk is accidental exposure of sensitive information in logs, crash output, or plugin responses.
8
+ FogClaw processes text from two surfaces: user prompts (`before_agent_start`) and tool results (`tool_result_persist`). Both may contain PII. The main risks are:
9
9
 
10
- Assume untrusted text arrives from user messages.
11
-
12
- For this repo, a concrete threat is that PII can appear in plain text and be reflected back in a wrong form (for example, redacted text leaking original spans) if redaction logic or logging is incorrect.
10
+ 1. **PII leaking through unscanned paths.** Any text surface that FogClaw does not hook into is a gap. Currently covered: user prompts and tool results. Not yet covered: outbound messages (`message_sending`), historical messages, compacted summaries.
11
+ 2. **Redaction logic errors.** If redaction produces malformed output (e.g., offset miscalculation), original PII spans could leak through or be partially visible.
12
+ 3. **Accidental PII in logs/errors.** Audit entries, error messages, and crash output must never contain raw PII values.
13
+ 4. **Regex false negatives.** The synchronous tool result path uses regex-only detection. Edge-case PII formats (international phone numbers, non-standard SSN formatting) may not match.
14
+ 5. **GLiNER unavailability.** If the ONNX model fails to load, the prompt-level scanner degrades to regex-only mode silently. Users may not realize unstructured entities (names, organizations) are not being detected.
13
15
 
14
16
  ## Auth Model
15
17
 
@@ -0,0 +1,293 @@
1
+ ---
2
+ slug: 2026-02-17-feat-tool-result-pii-scanning
3
+ status: active
4
+ phase: plan
5
+ plan_mode: lightweight
6
+ detail_level: more
7
+ priority: high
8
+ owner: sidmohan
9
+ ---
10
+
11
+ # Add PII scanning to tool results via tool_result_persist hook
12
+
13
+ This Plan is a living document. Keep `Progress`, `Surprises & Discoveries`, `Decision Log`, `Outcomes & Retrospective`, and `Revision Notes` current as work proceeds.
14
+
15
+ This plan must be maintained in accordance with `docs/PLANS.md`.
16
+
17
+ ## Purpose / Big Picture
18
+
19
+ FogClaw currently scans only the user prompt for PII. When an agent reads a file, fetches a web page, or queries an API, the tool result flows into the session transcript unscanned. After this change, FogClaw will intercept every tool result via OpenClaw's `tool_result_persist` hook and redact PII spans (SSN, email, phone, credit card, IP address, date, zip code) before the content is persisted to the session. The agent will see `[SSN_1]` instead of `123-45-6789`.
20
+
21
+ To verify it works: install FogClaw in OpenClaw, ask the agent to read a file that contains a phone number and an SSN, then inspect the session transcript. The raw values should be replaced with redaction tokens.
22
+
23
+ ## Progress
24
+
25
+ - [x] (2026-02-17T17:28:00Z) P1 [M1] Create `src/extract.ts` with `extractText` and `replaceText` functions
26
+ - [x] (2026-02-17T17:28:00Z) P2 [M1] Create `tests/extract.test.ts` covering string content, content block arrays, nested structures, empty/null, and non-text types
27
+ - [x] (2026-02-17T17:28:00Z) P3 [M1] All extract tests pass — 27 tests passed
28
+ - [x] (2026-02-17T17:29:00Z) P4 [M2] Create `src/tool-result-handler.ts` with synchronous `createToolResultHandler` factory
29
+ - [x] (2026-02-17T17:29:00Z) P5 [M2] Create `tests/tool-result-handler.test.ts` covering scanning, redaction, audit logging, allowlist, and edge cases
30
+ - [x] (2026-02-17T17:29:00Z) P6 [M2] Register `tool_result_persist` hook in `src/index.ts`
31
+ - [x] (2026-02-17T17:29:00Z) P7 [M2] All tool-result-handler tests pass — 21 tests passed
32
+ - [x] (2026-02-17T17:30:00Z) P8 [M3] Extend `tests/plugin-smoke.test.ts` with `tool_result_persist` hook registration and transformation tests
33
+ - [x] (2026-02-17T17:30:00Z) P9 [M3] Full test suite passes — 149 tests, 8 files, 0 failures
34
+ - [x] (2026-02-17T17:30:00Z) P10 [M3] Commit all changes — 3b7564f
35
+
36
+ ## Surprises & Discoveries
37
+
38
+ - Observation: The Scanner class's `regexEngine` field is private, so we instantiated a fresh `RegexEngine` directly in `register()` rather than exposing the Scanner's internal instance.
39
+ Evidence: `const toolResultRegex = new RegexEngine();` in src/index.ts. RegexEngine is stateless (only uses pattern matching), so a separate instance is functionally identical.
40
+
41
+ - Observation: The null byte separator approach for multi-block content works cleanly — regex PII patterns never match across `\0` boundaries.
42
+ Evidence: 27 extract tests pass including multi-block scenarios with mixed text/image blocks.
43
+
44
+ ## Decision Log
45
+
46
+ - Decision: Use RegexEngine and redact() directly instead of going through Scanner
47
+ Rationale: Scanner.scan() is declared `async` (returns a Promise) even when GLiNER is disabled, because the method signature is `async scan(...)`. The `tool_result_persist` hook in OpenClaw is synchronous-only — if a handler returns a Promise, OpenClaw logs a warning and ignores the result. RegexEngine.scan() and redact() are both fully synchronous, so we call them directly.
48
+ Date/Author: 2026-02-17, sidmohan
49
+
50
+ - Decision: All guardrail modes (redact, block, warn) produce span-level redaction in tool results
51
+ Rationale: Unlike `before_agent_start` where "block" can only prepend a warning context, `tool_result_persist` actually transforms the message. Span-level redaction is the safest behavior — it removes the PII while preserving surrounding context that the agent needs to reason. Replacing the entire tool result would destroy useful non-PII information.
52
+ Date/Author: 2026-02-17, sidmohan
53
+
54
+ - Decision: Reuse existing FogClaw config (guardrail_mode, entityActions, redactStrategy, allowlist)
55
+ Rationale: Users should have one mental model — "I set SSN to block, and it's blocked everywhere." Adding a separate config section for tool results would create inconsistency and confusion. If a user needs different behavior per-surface, that can be a future initiative.
56
+ Date/Author: 2026-02-17, sidmohan
57
+
58
+ ## Outcomes & Retrospective
59
+
60
+ All three milestones completed. FogClaw now scans tool results for PII via `tool_result_persist` hook using the regex engine synchronously. 149 tests pass across 8 test files with zero regressions. New modules: `src/extract.ts` (text extraction/replacement), `src/tool-result-handler.ts` (synchronous handler factory). The implementation adds 52 new tests (27 extract + 21 handler + 4 smoke).
61
+
62
+ ## Context and Orientation
63
+
64
+ FogClaw is an OpenClaw plugin that detects and redacts PII in agent conversations. The plugin lives at `/Users/sidmohan/Projects/datafog/fogclaw`.
65
+
66
+ Key files relevant to this plan:
67
+
68
+ - `src/index.ts` — Plugin entry point. Exports a plugin object with `id`, `name`, and `register(api)`. The `register` function loads config, initializes the Scanner, registers the `before_agent_start` hook, and registers three tools (`fogclaw_scan`, `fogclaw_preview`, `fogclaw_redact`). This is where we will add the `tool_result_persist` hook registration.
69
+
70
+ - `src/engines/regex.ts` — The RegexEngine class. Has a `scan(text: string): Entity[]` method that is fully synchronous. Detects 7 PII types: EMAIL, PHONE, SSN, CREDIT_CARD, IP_ADDRESS, DATE, ZIP_CODE. Each match gets confidence 1.0 and source "regex".
71
+
72
+ - `src/redactor.ts` — The `redact(text, entities, strategy)` function. Fully synchronous. Takes text, detected entities, and a strategy ("token", "mask", or "hash"). Returns `{ redacted_text, mapping, entities }`. Sorts entities by position descending and replaces from end to start to avoid offset corruption.
73
+
74
+ - `src/types.ts` — Type definitions including `Entity`, `RedactStrategy`, `GuardrailAction`, `FogClawConfig`, `ScanResult`, `RedactResult`. Also has `canonicalType()` for normalizing entity labels and `CANONICAL_TYPE_MAP`.
75
+
76
+ - `src/config.ts` — `loadConfig(raw)` merges defaults with overrides and validates. The `FogClawConfig` type includes `guardrail_mode`, `entityActions`, `redactStrategy`, `allowlist`, `auditEnabled`, and others.
77
+
78
+ - `src/scanner.ts` — The `Scanner` class that orchestrates regex + GLiNER engines. Its `scan()` method is `async` (cannot be used in synchronous hooks). Includes `filterByPolicy()` which applies allowlist filtering — we will need to replicate or extract this logic for the synchronous path.
79
+
80
+ - `tests/plugin-smoke.test.ts` — Integration tests for the plugin contract. Creates a mock `api` object with `pluginConfig`, `logger`, `on()`, and `registerTool()`. Tests verify hook registration and tool behavior.
81
+
82
+ OpenClaw's `tool_result_persist` hook contract (from OpenClaw's `src/plugins/types.ts`):
83
+
84
+ - **Event type**: `{ toolName?: string, toolCallId?: string, message: AgentMessage, isSynthetic?: boolean }`
85
+ - **Context type**: `{ agentId?: string, sessionKey?: string, toolName?: string, toolCallId?: string }`
86
+ - **Result type**: `{ message?: AgentMessage }` — return a modified message, or void to leave it unchanged
87
+ - **Execution**: Synchronous only. If a handler returns a Promise, OpenClaw warns and ignores the result.
88
+ - **Where it runs**: Inside `SessionManager.appendMessage`, via `session-tool-result-guard-wrapper.ts`. Fires on every tool result before it is written to the session transcript.
89
+
90
+ The `AgentMessage` type varies by provider and tool, but tool results typically contain text content in one of these shapes:
91
+ - A plain string
92
+ - An array of content blocks, each with `{ type: "text", text: string }` or `{ type: "image", ... }`
93
+ - A structured object with a `content` property that is one of the above
94
+
95
+ ## Milestones
96
+
97
+ ### Milestone 1 — Text extraction and replacement utilities
98
+
99
+ After this milestone, FogClaw will have a utility module that can defensively extract all text from an `AgentMessage` tool result payload (regardless of its internal shape) and replace text spans within it. This is the foundation for scanning — you need to get text out of the message to scan it, and put redacted text back in.
100
+
101
+ The module will be at `src/extract.ts` with two exported functions:
102
+
103
+ - `extractText(message: unknown): string` — walks the message structure and concatenates all text content into a single string, with segment boundaries marked so offsets can be mapped back. Returns empty string for non-text content.
104
+ - `replaceText(message: unknown, redactedText: string): unknown` — takes the original message and a redacted version of the extracted text, and returns a new message object with text content replaced. Preserves the original structure (arrays of content blocks stay as arrays, etc.).
105
+
106
+ Verification: run `pnpm test tests/extract.test.ts` and see all tests pass, covering: plain string messages, content block arrays with mixed text/image blocks, nested content properties, empty/null messages, and messages with no text content.
107
+
108
+ ### Milestone 2 — Synchronous tool result handler
109
+
110
+ After this milestone, FogClaw will have a handler factory at `src/tool-result-handler.ts` that produces a synchronous `tool_result_persist` handler, and the handler will be registered in `src/index.ts`.
111
+
112
+ The factory function `createToolResultHandler(config, regexEngine, logger?)` returns a function with the signature `(event, ctx) => { message } | void`. The handler:
113
+
114
+ 1. Extracts text from `event.message` using `extractText`
115
+ 2. Scans text with `regexEngine.scan(text)` (synchronous)
116
+ 3. Filters results through the allowlist (replicating `Scanner.filterByPolicy` logic synchronously)
117
+ 4. Determines per-entity action from `config.entityActions` with `config.guardrail_mode` as fallback
118
+ 5. Redacts all actionable entities using `redact()` (synchronous)
119
+ 6. Replaces text in the message using `replaceText`
120
+ 7. Emits an audit log entry if `config.auditEnabled` and entities were found
121
+ 8. Returns `{ message: modifiedMessage }` if any redaction occurred, or `void` if no PII found
122
+
123
+ The hook will be registered in `src/index.ts` inside the `register(api)` function, alongside the existing `before_agent_start` hook:
124
+
125
+ api.on("tool_result_persist", handler);
126
+
127
+ Verification: run `pnpm test tests/tool-result-handler.test.ts` and see all tests pass, covering: SSN redaction in tool results, email/phone detection, allowlist exclusion, audit log emission, no-op when no PII found, and various message shapes.
128
+
129
+ ### Milestone 3 — Integration smoke test
130
+
131
+ After this milestone, the existing plugin smoke test at `tests/plugin-smoke.test.ts` will be extended to verify that FogClaw registers a `tool_result_persist` hook and that invoking it with a tool result containing PII produces a transformed message.
132
+
133
+ Verification: run `pnpm test` (full suite) and see all tests pass with no regressions.
134
+
135
+ ## Plan of Work
136
+
137
+ The work proceeds in three sequential steps. Each builds on the previous.
138
+
139
+ **Step 1: Text extraction module.** Create `src/extract.ts` with `extractText` and `replaceText`. The `extractText` function should handle these `AgentMessage` shapes: (a) the message itself is a string, (b) the message has a `content` property that is a string, (c) the message has a `content` property that is an array of blocks where each text block has `{ type: "text", text: string }`. For arrays, concatenate text blocks with a newline separator and track the offset ranges so `replaceText` can map redacted text back to the correct blocks. Create `tests/extract.test.ts` with tests for each shape plus edge cases (null, undefined, empty string, image-only content blocks, deeply nested content).
140
+
141
+ **Step 2: Tool result handler.** Create `src/tool-result-handler.ts`. Import `RegexEngine` from `src/engines/regex.ts`, `redact` from `src/redactor.ts`, `extractText`/`replaceText` from `src/extract.ts`, and types from `src/types.ts`. The factory function `createToolResultHandler` takes `FogClawConfig`, a `RegexEngine` instance, and an optional logger object. It returns a synchronous handler function. Inside the handler: extract text, scan, filter by allowlist (replicate the allowlist filtering logic from `Scanner.filterByPolicy` in `src/scanner.ts` — the filtering checks `config.allowlist.values`, `config.allowlist.patterns`, and `config.allowlist.entities`), determine actions, redact, replace, audit, return. Then update `src/index.ts` to call `createToolResultHandler` during registration and register the returned handler with `api.on("tool_result_persist", handler)`. Create `tests/tool-result-handler.test.ts`.
142
+
143
+ **Step 3: Smoke test extension.** In `tests/plugin-smoke.test.ts`, add a test that verifies `tool_result_persist` appears in the registered hooks after `register(api)` is called. Add a second test that invokes the hook handler with a mock tool result message containing an SSN, and asserts the returned message has the SSN replaced with a redaction token like `[SSN_1]`.
144
+
145
+ ## Concrete Steps
146
+
147
+ All commands run from the FogClaw repo root at `/Users/sidmohan/Projects/datafog/fogclaw`.
148
+
149
+ After creating `src/extract.ts` and `tests/extract.test.ts`:
150
+
151
+ pnpm test tests/extract.test.ts
152
+
153
+ Expected: all extract tests pass (text extraction from various message shapes, replacement, edge cases).
154
+
155
+ After creating `src/tool-result-handler.ts` and `tests/tool-result-handler.test.ts` and updating `src/index.ts`:
156
+
157
+ pnpm test tests/tool-result-handler.test.ts
158
+
159
+ Expected: all handler tests pass (scanning, redaction, audit, allowlist, no-op cases).
160
+
161
+ After extending `tests/plugin-smoke.test.ts`:
162
+
163
+ pnpm test tests/plugin-smoke.test.ts
164
+
165
+ Expected: all smoke tests pass, including new `tool_result_persist` tests.
166
+
167
+ Full suite validation:
168
+
169
+ pnpm test
170
+
171
+ Expected: all tests pass, no regressions in existing `before_agent_start`, scanner, redactor, regex, or config tests.
172
+
173
+ Type check:
174
+
175
+ pnpm lint
176
+
177
+ Expected: no type errors.
178
+
179
+ ## Validation and Acceptance
180
+
181
+ The feature is complete when:
182
+
183
+ 1. `pnpm test` passes with all existing tests plus new tests for extract, tool-result-handler, and extended smoke tests.
184
+ 2. `pnpm lint` passes with no type errors.
185
+ 3. A tool result message containing `"Call 555-123-4567 or email john@example.com"` is passed to the `tool_result_persist` handler and the returned message contains `"Call [PHONE_1] or email [EMAIL_1]"` (with token strategy) and the original values do not appear.
186
+ 4. A tool result message containing no PII returns `void` (no modification).
187
+ 5. An allowlisted value (e.g., `noreply@example.com`) is not redacted even when detected.
188
+ 6. When `auditEnabled: true`, the logger receives an audit entry with `source: "tool_result"`, entity count, and labels but no raw PII values.
189
+
190
+ ## Idempotence and Recovery
191
+
192
+ All changes are additive — new files (`src/extract.ts`, `src/tool-result-handler.ts`) and new tests. No existing files are modified except `src/index.ts` (adding a hook registration) and `tests/plugin-smoke.test.ts` (adding test cases).
193
+
194
+ If a step fails partway, delete the partially created files and restart from the milestone. No database migrations, no state files, no destructive operations.
195
+
196
+ Running `pnpm test` at any point is safe and idempotent.
197
+
198
+ ## Artifacts and Notes
199
+
200
+ Full test suite output:
201
+
202
+ ✓ tests/extract.test.ts (27 tests) 4ms
203
+ ✓ tests/config.test.ts (6 tests) 4ms
204
+ ✓ tests/redactor.test.ts (21 tests) 6ms
205
+ ✓ tests/regex.test.ts (39 tests) 11ms
206
+ ✓ tests/tool-result-handler.test.ts (21 tests) 10ms
207
+ ✓ tests/gliner.test.ts (12 tests) 10ms
208
+ ✓ tests/plugin-smoke.test.ts (8 tests) 9ms
209
+ ✓ tests/scanner.test.ts (15 tests) 13ms
210
+
211
+ Test Files 8 passed (8)
212
+ Tests 149 passed (149)
213
+
214
+ Type check: `npx tsc --noEmit` — clean, no errors.
215
+
216
+ ## Interfaces and Dependencies
217
+
218
+ **New module `src/extract.ts`:**
219
+
220
+ export function extractText(message: unknown): string
221
+ export function replaceText(message: unknown, redactedText: string): unknown
222
+
223
+ **New module `src/tool-result-handler.ts`:**
224
+
225
+ import { RegexEngine } from "./engines/regex.js";
226
+ import { FogClawConfig } from "./types.js";
227
+
228
+ interface Logger {
229
+ info(msg: string): void;
230
+ warn(msg: string): void;
231
+ }
232
+
233
+ interface ToolResultPersistEvent {
234
+ toolName?: string;
235
+ toolCallId?: string;
236
+ message: unknown;
237
+ isSynthetic?: boolean;
238
+ }
239
+
240
+ interface ToolResultPersistContext {
241
+ agentId?: string;
242
+ sessionKey?: string;
243
+ toolName?: string;
244
+ toolCallId?: string;
245
+ }
246
+
247
+ export function createToolResultHandler(
248
+ config: FogClawConfig,
249
+ regexEngine: RegexEngine,
250
+ logger?: Logger,
251
+ ): (event: ToolResultPersistEvent, ctx: ToolResultPersistContext) =>
252
+ { message: unknown } | void
253
+
254
+ **Modified `src/index.ts`:**
255
+
256
+ Inside the `register(api)` function, after the existing `before_agent_start` registration, add:
257
+
258
+ const toolResultHandler = createToolResultHandler(config, scanner.regexEngine, api.logger);
259
+ api.on("tool_result_persist", toolResultHandler);
260
+
261
+ This requires exposing `regexEngine` from the Scanner class (currently private). Either make it a public property or instantiate a separate RegexEngine in `register()`.
262
+
263
+ **No new dependencies.** All imports are from existing FogClaw modules or Node built-ins.
264
+
265
+ ## Pull Request
266
+
267
+ Populated by `he-github`.
268
+
269
+ - pr:
270
+ - branch:
271
+ - commit:
272
+ - ci:
273
+
274
+ ## Review Findings
275
+
276
+ Populated by `he-review`.
277
+
278
+ ## Verify/Release Decision
279
+
280
+ Populated by `he-verify-release`.
281
+
282
+ - decision:
283
+ - date:
284
+ - open findings by priority (if any):
285
+ - evidence:
286
+ - rollback:
287
+ - post-release checks:
288
+ - owner:
289
+
290
+ ## Revision Notes
291
+
292
+ - 2026-02-17T00:00:00Z: Initialized plan from template. Reason: establish PLANS-compliant execution baseline for tool result PII scanning.
293
+ - 2026-02-17T17:30:00Z: All milestones completed. Updated Progress, Surprises & Discoveries, Outcomes & Retrospective, and Artifacts sections with implementation evidence.
@@ -0,0 +1,93 @@
1
+ ---
2
+ slug: 2026-02-17-feat-outbound-message-pii-scanning
3
+ status: intake-complete
4
+ date: 2026-02-17T00:00:00Z
5
+ owner: sidmohan
6
+ plan_mode: lightweight
7
+ spike_recommended: no
8
+ priority: high
9
+ ---
10
+
11
+ # feat: Add PII scanning to outbound messages via message_sending hook
12
+
13
+ ## Purpose / Big Picture
14
+
15
+ FogClaw now scans user prompts (`before_agent_start`) and tool results (`tool_result_persist`), but outbound messages — the agent's final responses delivered to Telegram, Slack, Discord, etc. — are not scanned. If PII slips through into the agent's response (hallucinated, echoed, or reassembled from partial data), it reaches external channels unredacted.
16
+
17
+ By hooking into OpenClaw's `message_sending` lifecycle, FogClaw adds a last-chance gate that scans and redacts PII in outbound messages before they are delivered to recipients.
18
+
19
+ Note: `message_sending` is defined in OpenClaw's type system but not yet invoked upstream. This handler will activate automatically when OpenClaw wires the hook into its outbound message flow.
20
+
21
+ ## Scope
22
+
23
+ ### In Scope
24
+
25
+ - Register a `message_sending` hook handler in FogClaw's plugin registration
26
+ - Scan `event.content` (outbound message text) using the **full Scanner** (regex + GLiNER) since this hook is async-capable
27
+ - Apply existing `guardrail_mode`, `entityActions`, `redactStrategy`, and `allowlist` config
28
+ - Redact PII spans in the outbound message content (all modes produce span-level redaction, never cancel)
29
+ - Return `{ content: redactedText }` when PII is found
30
+ - Emit audit log entries when `auditEnabled: true`
31
+ - Add unit tests for the handler
32
+ - Extend plugin smoke test
33
+
34
+ ### Boundaries
35
+
36
+ - **No message cancellation.** FogClaw will never return `cancel: true`. Span-level redaction is always preferred over dropping messages silently.
37
+ - **No new config surface.** Reuse existing FogClaw config.
38
+ - **No changes to OpenClaw upstream.** Handler will activate when OpenClaw wires the hook.
39
+ - **No scanning of `event.metadata`.** Only `event.content` (the text delivered to the recipient).
40
+
41
+ ## Non-Goals
42
+
43
+ - Cancelling message delivery
44
+ - Scanning message metadata or recipient addresses
45
+ - Modifying recipient routing
46
+
47
+ ## Risks
48
+
49
+ - **Hook not invoked upstream.** The handler exists but won't fire until OpenClaw activates `message_sending`. This is accepted — the code is ready and waiting.
50
+ - **GLiNER latency on outbound path.** Scanner.scan() is async and may add 50-200ms per message. This is acceptable for outbound messages (not a hot-path like tool_result_persist) and provides coverage for person names and organizations.
51
+
52
+ ## Requirements
53
+
54
+ | ID | Priority | Requirement |
55
+ |---|---|---|
56
+ | R1 | critical | Register a `message_sending` hook handler that scans outbound message content for PII using the full Scanner (regex + GLiNER) |
57
+ | R2 | critical | Redact detected PII spans using the configured `redactStrategy` |
58
+ | R3 | critical | Return `{ content: redactedText }` when PII is found; return void when clean |
59
+ | R4 | high | Apply existing `entityActions`, `guardrail_mode`, and `allowlist` config; all actions produce span-level redaction |
60
+ | R5 | high | Never return `cancel: true` — always deliver the (redacted) message |
61
+ | R6 | medium | Emit audit log entry with `source: "outbound"` when PII is detected and `auditEnabled: true` |
62
+ | R7 | low | Handler may be async (Scanner.scan() returns a Promise) |
63
+
64
+ ## Success Criteria
65
+
66
+ - Unit tests pass for the message sending handler covering PII detection, redaction, allowlist, audit logging, and no-op cases
67
+ - Plugin smoke test verifies `message_sending` hook registration
68
+ - Plugin smoke test verifies PII in outbound content is redacted
69
+ - All existing tests pass (no regression)
70
+
71
+ ## Constraints
72
+
73
+ - Must not introduce new dependencies
74
+ - Must not change the existing `FogClawConfig` type
75
+ - Must reuse the existing Scanner instance (not create a new one)
76
+
77
+ ## Priority
78
+
79
+ - priority: high
80
+ - rationale: This is the last-chance safety net before PII reaches external messaging channels. Even if upstream scanning catches most PII, outbound scanning prevents hallucinated or reassembled PII from leaking.
81
+
82
+ ## Initial Milestone Candidates
83
+
84
+ - M1: Create `src/message-sending-handler.ts` with async handler factory, plus unit tests
85
+ - M2: Register hook in `src/index.ts`, extend plugin smoke test, full suite validation
86
+
87
+ ## Handoff
88
+
89
+ After spec approval, proceed directly to implementation (lightweight plan mode — code mirrors the established `tool-result-handler.ts` pattern).
90
+
91
+ ## Revision Notes
92
+
93
+ - 2026-02-17T00:00:00Z: Initialized spec. message_sending hook is typed but not invoked in OpenClaw; handler ships as future-ready.
@@ -0,0 +1,122 @@
1
+ ---
2
+ slug: 2026-02-17-feat-tool-result-pii-scanning
3
+ status: intake-complete
4
+ date: 2026-02-17T00:00:00Z
5
+ owner: sidmohan
6
+ plan_mode: lightweight
7
+ spike_recommended: no
8
+ priority: high
9
+ ---
10
+
11
+ # feat: Add PII scanning to tool results via tool_result_persist hook
12
+
13
+ ## Purpose / Big Picture
14
+
15
+ FogClaw currently only scans the user prompt text (`before_agent_start`). The majority of PII entering an agent's context comes from **tool results** — file reads, web fetches, API calls, database queries. This content bypasses FogClaw entirely today.
16
+
17
+ By hooking into OpenClaw's `tool_result_persist` lifecycle, FogClaw can scan and redact PII in tool results **before they are persisted to the session transcript**, closing the largest gap in FogClaw's coverage.
18
+
19
+ ## Scope
20
+
21
+ ### In Scope
22
+
23
+ - Register a `tool_result_persist` hook handler in FogClaw's plugin registration
24
+ - Extract text content from `AgentMessage` tool result payloads
25
+ - Scan extracted text using the **regex engine only** (synchronous constraint)
26
+ - Apply the existing `guardrail_mode`, `entityActions`, `redactStrategy`, and `allowlist` config to detected entities
27
+ - Redact PII spans in tool result text content (all modes — redact, block, warn — produce span-level redaction in tool results)
28
+ - Emit audit log entries for tool result detections when `auditEnabled: true`
29
+ - Add unit tests for the new hook handler
30
+ - Add integration test confirming the hook registers and transforms tool results
31
+
32
+ ### Boundaries
33
+
34
+ - **No GLiNER on this path.** The `tool_result_persist` hook is synchronous-only; async handlers are rejected by OpenClaw. Regex covers structured PII (SSN, email, phone, credit card, IP, date, zip). Unstructured entity detection (person names, organizations) is out of scope for this hook.
35
+ - **No `before_tool_call` hook.** This hook exists in OpenClaw's type system but has zero active invocation sites upstream. Will be addressed in a future initiative once OpenClaw wires it in.
36
+ - **No `message_sending` hook.** Outbound message scanning is a separate priority.
37
+ - **No scanning of `event.messages` history.** Historical message scanning is a separate priority.
38
+ - **No new config surface.** Reuse existing FogClaw config — no `toolResultScanning` sub-object.
39
+ - **No changes to OpenClaw upstream.** This initiative is FogClaw-only.
40
+
41
+ ## Non-Goals
42
+
43
+ - Blocking tool execution (requires `before_tool_call`, which is not wired upstream)
44
+ - Modifying files on disk
45
+ - Scanning binary/image content in tool results
46
+ - Real-time GLiNER inference on tool results
47
+
48
+ ## Risks
49
+
50
+ - **Performance on hot path.** `tool_result_persist` runs synchronously on every tool result. Regex scanning is sub-millisecond for typical payloads, but very large tool results (e.g., reading a 10K-line file) could add measurable latency. Mitigation: benchmark and consider a size cap with configurable threshold.
51
+ - **AgentMessage structure varies.** Tool results are typed as `AgentMessage`, whose internal structure depends on the tool and provider. Text extraction must handle multiple content formats without crashing on unexpected shapes. Mitigation: defensive extraction with fallback to no-op.
52
+ - **Redaction alters tool output semantics.** Replacing `123-45-6789` with `[SSN_1]` in a tool result changes what the model sees. This is the intended behavior, but could cause unexpected downstream effects if the model tries to use the redacted value literally. Mitigation: this is inherent to the feature and matches existing `before_agent_start` behavior.
53
+
54
+ ## Rollout
55
+
56
+ - Ship as part of next FogClaw patch release (0.1.7 or 0.2.0)
57
+ - Enabled by default when FogClaw is enabled (no separate toggle)
58
+ - Audit logging captures tool result scans for observability
59
+
60
+ ## Validation and Acceptance Signals
61
+
62
+ - Unit tests pass for text extraction from various `AgentMessage` shapes
63
+ - Unit tests pass for regex scanning + redaction of tool result content
64
+ - Integration test confirms `tool_result_persist` hook registers via `api.on()`
65
+ - Integration test confirms a tool result containing PII is transformed before persistence
66
+ - Audit log entries are emitted for tool result detections
67
+ - Existing `before_agent_start` tests continue to pass (no regression)
68
+ - Manual verification: install FogClaw in OpenClaw, have agent read a file with PII, confirm session transcript shows redacted content
69
+
70
+ ## Requirements
71
+
72
+ | ID | Priority | Requirement |
73
+ |---|---|---|
74
+ | R1 | critical | Register a `tool_result_persist` hook handler that scans tool result text for PII using the regex engine |
75
+ | R2 | critical | Redact detected PII spans in tool result messages using the configured `redactStrategy` (token/mask/hash) |
76
+ | R3 | critical | Handler must be synchronous (no Promises returned) — OpenClaw rejects async `tool_result_persist` handlers |
77
+ | R4 | high | Apply existing `entityActions` and `guardrail_mode` config to determine per-entity action; all actions produce span-level redaction in tool results |
78
+ | R5 | high | Respect existing `allowlist` config (global values, patterns, per-entity lists) |
79
+ | R6 | high | Extract text content defensively from `AgentMessage` payloads — handle string content, array-of-content-blocks, and unexpected shapes without throwing |
80
+ | R7 | medium | Emit audit log entry per tool result scan when `auditEnabled: true`, including tool name, entity count, and labels (no raw PII values in logs) |
81
+ | R8 | medium | Skip scanning for tool results with no extractable text content (binary, empty, non-string) |
82
+ | R9 | low | Include `source: "tool_result"` in audit log entries to distinguish from prompt-level scans |
83
+
84
+ ## Key Decisions
85
+
86
+ - **Regex-only on hot path**: GLiNER is async and cannot run in a synchronous hook. Regex covers the 7 structured PII types (SSN, email, phone, credit card, IP, date, zip) at sub-millisecond latency. This is a deliberate tradeoff — unstructured entities (person names, orgs) are not scanned in tool results.
87
+ - **Reuse existing config**: No separate config section for tool result scanning. The same `guardrail_mode`, `entityActions`, `redactStrategy`, and `allowlist` apply everywhere. Simpler mental model for users.
88
+ - **Span-level redaction for all modes**: Even when `entityActions` says `block` for an entity type, the tool result is redacted at the span level (not replaced entirely). This preserves non-PII context for the agent while removing sensitive values.
89
+
90
+ ## Success Criteria
91
+
92
+ - PII in tool results (file reads, web fetches, etc.) is redacted before entering the session transcript
93
+ - Regex engine detects SSN, email, phone, credit card, IP, date, and zip in tool result content
94
+ - No measurable latency impact for typical tool results (<1KB text)
95
+ - Audit log captures tool result scan events with entity counts and labels
96
+ - All existing tests pass; new tests cover the hook handler, text extraction, and edge cases
97
+
98
+ ## Constraints
99
+
100
+ - `tool_result_persist` handler MUST be synchronous (OpenClaw constraint)
101
+ - Must not introduce new dependencies
102
+ - Must not change the existing `FogClawConfig` type (reuse existing fields)
103
+ - Regex engine only — no ONNX/GLiNER on this path
104
+
105
+ ## Priority
106
+
107
+ - priority: high
108
+ - rationale: This closes the single largest gap in FogClaw's PII coverage. Tool results are the primary vector for PII entering agent context, and this hook is the only active interception point OpenClaw provides for that data flow.
109
+
110
+ ## Initial Milestone Candidates
111
+
112
+ - M1: Text extraction utility — defensively extract text from `AgentMessage` tool result payloads, handling string content, content block arrays, and edge cases. Likely files: `src/extract.ts`, `tests/extract.test.ts`.
113
+ - M2: `tool_result_persist` hook handler — register the hook, wire in regex scanning + redaction + audit logging, return transformed message. Likely files: `src/index.ts`, `tests/tool-result-hook.test.ts`.
114
+ - M3: Integration smoke test — end-to-end test confirming a registered FogClaw plugin transforms a tool result containing PII. Likely files: `tests/plugin-smoke.test.ts` (extend existing).
115
+
116
+ ## Handoff
117
+
118
+ After spec approval, proceed to `he-plan` for implementation breakdown. No spike needed — the OpenClaw hook contract is well-documented and the regex engine + redactor already exist in FogClaw.
119
+
120
+ ## Revision Notes
121
+
122
+ - 2026-02-17T00:00:00Z: Initialized spec from template. Reason: establish intake baseline for tool result PII scanning via `tool_result_persist` hook.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "fogclaw",
3
3
  "name": "FogClaw",
4
- "version": "0.1.6",
4
+ "version": "0.2.0",
5
5
  "description": "PII detection & custom entity redaction plugin powered by DataFog",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datafog/fogclaw",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "OpenClaw plugin for PII detection & custom entity redaction powered by DataFog",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/extract.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Utilities for extracting text from AgentMessage tool result payloads
3
+ * and replacing text content after redaction.
4
+ *
5
+ * AgentMessage shapes handled:
6
+ * - Plain string
7
+ * - Object with `content: string`
8
+ * - Object with `content: [{ type: "text", text: "..." }, ...]`
9
+ *
10
+ * When multiple text blocks exist in a content array, they are joined
11
+ * with a null byte separator (\0) so entity offsets stay valid across
12
+ * the concatenated string. replaceText splits on the same separator
13
+ * to map redacted text back to individual blocks.
14
+ */
15
+
16
+ // Separator between text segments from content block arrays.
17
+ // Null byte won't appear in regex PII patterns or normal text content.
18
+ const SEGMENT_SEP = "\0";
19
+
20
+ /**
21
+ * Extract all text content from an AgentMessage tool result payload.
22
+ * Returns an empty string if no text content is found.
23
+ */
24
+ export function extractText(message: unknown): string {
25
+ if (message == null) return "";
26
+ if (typeof message === "string") return message;
27
+ if (typeof message !== "object") return "";
28
+
29
+ const msg = message as Record<string, unknown>;
30
+ const content = msg.content;
31
+
32
+ if (content == null) return "";
33
+ if (typeof content === "string") return content;
34
+
35
+ if (Array.isArray(content)) {
36
+ const textParts: string[] = [];
37
+ for (const block of content) {
38
+ if (
39
+ block != null &&
40
+ typeof block === "object" &&
41
+ (block as Record<string, unknown>).type === "text" &&
42
+ typeof (block as Record<string, unknown>).text === "string"
43
+ ) {
44
+ textParts.push((block as Record<string, unknown>).text as string);
45
+ }
46
+ }
47
+ if (textParts.length === 0) return "";
48
+ return textParts.join(SEGMENT_SEP);
49
+ }
50
+
51
+ return "";
52
+ }
53
+
54
+ /**
55
+ * Replace text content in an AgentMessage tool result payload with
56
+ * the redacted version. Returns a shallow copy; does not mutate.
57
+ *
58
+ * If the message shape is not recognized or has no text, returns
59
+ * the original message unchanged.
60
+ */
61
+ export function replaceText(message: unknown, redactedText: string): unknown {
62
+ if (message == null) return message;
63
+ if (typeof message === "string") return redactedText;
64
+ if (typeof message !== "object") return message;
65
+
66
+ const msg = message as Record<string, unknown>;
67
+ const content = msg.content;
68
+
69
+ if (content == null) return message;
70
+
71
+ if (typeof content === "string") {
72
+ return { ...msg, content: redactedText };
73
+ }
74
+
75
+ if (Array.isArray(content)) {
76
+ const segments = redactedText.split(SEGMENT_SEP);
77
+ let segmentIndex = 0;
78
+
79
+ const newContent = content.map((block) => {
80
+ if (
81
+ block != null &&
82
+ typeof block === "object" &&
83
+ (block as Record<string, unknown>).type === "text" &&
84
+ typeof (block as Record<string, unknown>).text === "string" &&
85
+ segmentIndex < segments.length
86
+ ) {
87
+ const replaced = { ...(block as Record<string, unknown>), text: segments[segmentIndex] };
88
+ segmentIndex++;
89
+ return replaced;
90
+ }
91
+ return block;
92
+ });
93
+
94
+ return { ...msg, content: newContent };
95
+ }
96
+
97
+ return message;
98
+ }