shieldcortex 2.16.2 → 2.17.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/README.md +47 -2
- package/dashboard/.next/standalone/dashboard/.next/BUILD_ID +1 -1
- package/dashboard/.next/standalone/dashboard/.next/build-manifest.json +2 -2
- package/dashboard/.next/standalone/dashboard/.next/prerender-manifest.json +3 -3
- package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.html +2 -2
- package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.rsc +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.html +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.rsc +2 -2
- package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/dashboard/.next/standalone/dashboard/.next/server/app/index.html +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/index.rsc +3 -3
- package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/dashboard/.next/standalone/dashboard/.next/server/app/page_client-reference-manifest.js +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/chunks/ssr/dashboard_25b1b286._.js +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/pages/404.html +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/pages/500.html +2 -2
- package/dashboard/.next/standalone/dashboard/.next/server/server-reference-manifest.js +1 -1
- package/dashboard/.next/standalone/dashboard/.next/server/server-reference-manifest.json +1 -1
- package/dashboard/.next/standalone/dashboard/.next/static/chunks/{1bf33aa1c01418e1.js → 61a3c89b08347bc2.js} +1 -1
- package/dashboard/.next/standalone/dashboard/.next/static/chunks/c252c4de65df6d09.css +3 -0
- package/dist/api/visualization-server.d.ts.map +1 -1
- package/dist/api/visualization-server.js +30 -2
- package/dist/api/visualization-server.js.map +1 -1
- package/dist/cloud/cli.d.ts.map +1 -1
- package/dist/cloud/cli.js +21 -1
- package/dist/cloud/cli.js.map +1 -1
- package/dist/cloud/config.d.ts +23 -0
- package/dist/cloud/config.d.ts.map +1 -1
- package/dist/cloud/config.js +57 -0
- package/dist/cloud/config.js.map +1 -1
- package/dist/defence/__tests__/pipeline.test.js +45 -1
- package/dist/defence/__tests__/pipeline.test.js.map +1 -1
- package/dist/defence/index.d.ts +2 -2
- package/dist/defence/index.d.ts.map +1 -1
- package/dist/defence/index.js +1 -1
- package/dist/defence/index.js.map +1 -1
- package/dist/defence/pipeline.d.ts.map +1 -1
- package/dist/defence/pipeline.js +4 -0
- package/dist/defence/pipeline.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/integrations/__tests__/openclaw.test.d.ts +2 -0
- package/dist/integrations/__tests__/openclaw.test.d.ts.map +1 -0
- package/dist/integrations/__tests__/openclaw.test.js +72 -0
- package/dist/integrations/__tests__/openclaw.test.js.map +1 -0
- package/dist/integrations/__tests__/universal.test.d.ts +2 -0
- package/dist/integrations/__tests__/universal.test.d.ts.map +1 -0
- package/dist/integrations/__tests__/universal.test.js +144 -0
- package/dist/integrations/__tests__/universal.test.js.map +1 -0
- package/dist/integrations/index.d.ts +3 -0
- package/dist/integrations/index.d.ts.map +1 -1
- package/dist/integrations/index.js +2 -0
- package/dist/integrations/index.js.map +1 -1
- package/dist/integrations/openclaw.d.ts +38 -0
- package/dist/integrations/openclaw.d.ts.map +1 -0
- package/dist/integrations/openclaw.js +169 -0
- package/dist/integrations/openclaw.js.map +1 -0
- package/dist/integrations/universal.d.ts +62 -0
- package/dist/integrations/universal.d.ts.map +1 -0
- package/dist/integrations/universal.js +100 -0
- package/dist/integrations/universal.js.map +1 -0
- package/dist/lib.d.ts +3 -1
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +3 -1
- package/dist/lib.js.map +1 -1
- package/dist/setup/openclaw.d.ts.map +1 -1
- package/dist/setup/openclaw.js +3 -2
- package/dist/setup/openclaw.js.map +1 -1
- package/hooks/openclaw/cortex-memory/HOOK.md +32 -2
- package/hooks/openclaw/cortex-memory/handler.ts +213 -10
- package/package.json +9 -1
- package/plugins/openclaw/README.md +35 -1
- package/plugins/openclaw/dist/index.js +145 -5
- package/plugins/openclaw/index.ts +183 -6
- package/scripts/postinstall.mjs +34 -0
- package/dashboard/.next/standalone/dashboard/.next/static/chunks/bccda52164e63171.css +0 -3
- /package/dashboard/.next/standalone/dashboard/.next/static/{z7V0ywXg56o1kWteW7hN0 → rafRHTrrEzsWtJlg9d1Sf}/_buildManifest.js +0 -0
- /package/dashboard/.next/standalone/dashboard/.next/static/{z7V0ywXg56o1kWteW7hN0 → rafRHTrrEzsWtJlg9d1Sf}/_clientMiddlewareManifest.json +0 -0
- /package/dashboard/.next/standalone/dashboard/.next/static/{z7V0ywXg56o1kWteW7hN0 → rafRHTrrEzsWtJlg9d1Sf}/_ssgManifest.js +0 -0
|
@@ -7,7 +7,7 @@ Real-time defence scanning and memory extraction for OpenClaw v2026.2.15+.
|
|
|
7
7
|
| Hook | Action |
|
|
8
8
|
|------|--------|
|
|
9
9
|
| `llm_input` | Scans prompts + history through ShieldCortex defence pipeline. Logs threats, writes audit log, optionally syncs to cloud. **Fire-and-forget.** |
|
|
10
|
-
| `llm_output` |
|
|
10
|
+
| `llm_output` | Optional memory extraction from assistant responses (disabled by default). Saves to ShieldCortex memory via mcporter when enabled, with novelty/dedupe filtering to reduce noise. **Fire-and-forget.** |
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -39,6 +39,40 @@ Find the package root with `npm root -g` (global) or `npm root` (local).
|
|
|
39
39
|
- ShieldCortex installed globally (`npm i -g shieldcortex`) or at `~/ShieldCortex/`
|
|
40
40
|
- `mcporter` available via npx (for memory saves)
|
|
41
41
|
|
|
42
|
+
## Optional Auto-Memory
|
|
43
|
+
|
|
44
|
+
Auto-memory extraction is off by default to avoid duplicate/noisy memory when OpenClaw already has native memory.
|
|
45
|
+
|
|
46
|
+
Enable it:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx shieldcortex config --openclaw-auto-memory true
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Disable it:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx shieldcortex config --openclaw-auto-memory false
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Enable it in `~/.shieldcortex/config.json`:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"openclawAutoMemory": true
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Novelty filtering is enabled by default when auto-memory is on. Optional tuning keys:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"openclawAutoMemoryDedupe": true,
|
|
71
|
+
"openclawAutoMemoryNoveltyThreshold": 0.88,
|
|
72
|
+
"openclawAutoMemoryMaxRecent": 300
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
42
76
|
## Cloud Sync (optional)
|
|
43
77
|
|
|
44
78
|
Add your API key to `~/.shieldcortex/config.json`:
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* ShieldCortex Real-time Scanning Plugin for OpenClaw v2026.2.15+
|
|
3
3
|
*
|
|
4
4
|
* Hooks into llm_input/llm_output for real-time defence scanning
|
|
5
|
-
* and memory extraction. All operations are fire-and-forget.
|
|
5
|
+
* and optional memory extraction. All operations are fire-and-forget.
|
|
6
6
|
*/
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
8
9
|
import fs from "node:fs/promises";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { homedir } from "node:os";
|
|
@@ -20,6 +21,12 @@ async function loadConfig() {
|
|
|
20
21
|
}
|
|
21
22
|
return _config;
|
|
22
23
|
}
|
|
24
|
+
function isAutoMemoryEnabled(config) {
|
|
25
|
+
return config.openclawAutoMemory === true;
|
|
26
|
+
}
|
|
27
|
+
function isAutoMemoryDedupeEnabled(config) {
|
|
28
|
+
return config.openclawAutoMemoryDedupe !== false;
|
|
29
|
+
}
|
|
23
30
|
// ==================== SERVER CMD ====================
|
|
24
31
|
let _serverCmd = null;
|
|
25
32
|
async function resolveServerCmd() {
|
|
@@ -154,6 +161,10 @@ function extractUserContent(msgs) {
|
|
|
154
161
|
return out;
|
|
155
162
|
}
|
|
156
163
|
const AUDIT_DIR = path.join(homedir(), ".shieldcortex", "audit");
|
|
164
|
+
const NOVELTY_CACHE_FILE = path.join(homedir(), ".shieldcortex", "openclaw-memory-cache.json");
|
|
165
|
+
const DEFAULT_NOVELTY_THRESHOLD = 0.88;
|
|
166
|
+
const DEFAULT_MAX_RECENT = 300;
|
|
167
|
+
const MIN_NOVELTY_CHARS = 40;
|
|
157
168
|
async function auditLog(entry) {
|
|
158
169
|
try {
|
|
159
170
|
await fs.mkdir(AUDIT_DIR, { recursive: true });
|
|
@@ -175,6 +186,122 @@ async function cloudSync(threat) {
|
|
|
175
186
|
}
|
|
176
187
|
catch { }
|
|
177
188
|
}
|
|
189
|
+
function normalizeMemoryText(text) {
|
|
190
|
+
return String(text || "")
|
|
191
|
+
.toLowerCase()
|
|
192
|
+
.replace(/[`"'\\]/g, " ")
|
|
193
|
+
.replace(/https?:\/\/\S+/g, " ")
|
|
194
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
195
|
+
.replace(/\s+/g, " ")
|
|
196
|
+
.trim();
|
|
197
|
+
}
|
|
198
|
+
function hashToken(token) {
|
|
199
|
+
return createHash("sha1").update(token).digest("hex").slice(0, 12);
|
|
200
|
+
}
|
|
201
|
+
function buildTokenHashes(normalized) {
|
|
202
|
+
const words = normalized.split(" ").filter((w) => w.length >= 3);
|
|
203
|
+
const set = new Set();
|
|
204
|
+
for (let i = 0; i < words.length; i++) {
|
|
205
|
+
set.add(hashToken(words[i]));
|
|
206
|
+
if (i < words.length - 1)
|
|
207
|
+
set.add(hashToken(`${words[i]}_${words[i + 1]}`));
|
|
208
|
+
}
|
|
209
|
+
return Array.from(set).slice(0, 200);
|
|
210
|
+
}
|
|
211
|
+
function jaccardSimilarity(a, b) {
|
|
212
|
+
if (a.size === 0 || b.size === 0)
|
|
213
|
+
return 0;
|
|
214
|
+
let intersection = 0;
|
|
215
|
+
for (const item of a) {
|
|
216
|
+
if (b.has(item))
|
|
217
|
+
intersection++;
|
|
218
|
+
}
|
|
219
|
+
const union = a.size + b.size - intersection;
|
|
220
|
+
return union === 0 ? 0 : intersection / union;
|
|
221
|
+
}
|
|
222
|
+
function clamp(value, min, max) {
|
|
223
|
+
return Math.max(min, Math.min(max, value));
|
|
224
|
+
}
|
|
225
|
+
async function loadNoveltyCache(maxRecent) {
|
|
226
|
+
try {
|
|
227
|
+
const raw = JSON.parse(await fs.readFile(NOVELTY_CACHE_FILE, "utf-8"));
|
|
228
|
+
if (!Array.isArray(raw))
|
|
229
|
+
return [];
|
|
230
|
+
return raw
|
|
231
|
+
.filter((entry) => entry && typeof entry.hash === "string" && Array.isArray(entry.tokenHashes))
|
|
232
|
+
.slice(0, maxRecent);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async function saveNoveltyCache(entries) {
|
|
239
|
+
await fs.mkdir(path.dirname(NOVELTY_CACHE_FILE), { recursive: true });
|
|
240
|
+
await fs.writeFile(NOVELTY_CACHE_FILE, JSON.stringify(entries, null, 2) + "\n", "utf-8");
|
|
241
|
+
}
|
|
242
|
+
function inspectNovelty(content, entries, threshold) {
|
|
243
|
+
const normalized = normalizeMemoryText(content);
|
|
244
|
+
if (normalized.length < MIN_NOVELTY_CHARS) {
|
|
245
|
+
return { allow: true, contentHash: null, tokenHashes: [] };
|
|
246
|
+
}
|
|
247
|
+
const contentHash = createHash("sha256").update(normalized).digest("hex").slice(0, 24);
|
|
248
|
+
if (entries.some((entry) => entry.hash === contentHash)) {
|
|
249
|
+
return { allow: false, contentHash, tokenHashes: [], reason: "exact duplicate" };
|
|
250
|
+
}
|
|
251
|
+
const tokenHashes = buildTokenHashes(normalized);
|
|
252
|
+
const currentSet = new Set(tokenHashes);
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
const score = jaccardSimilarity(currentSet, new Set(entry.tokenHashes || []));
|
|
255
|
+
if (score >= threshold) {
|
|
256
|
+
return {
|
|
257
|
+
allow: false,
|
|
258
|
+
contentHash,
|
|
259
|
+
tokenHashes,
|
|
260
|
+
reason: `near duplicate (similarity ${score.toFixed(2)})`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { allow: true, contentHash, tokenHashes };
|
|
265
|
+
}
|
|
266
|
+
async function createNoveltyGate(config) {
|
|
267
|
+
const thresholdRaw = Number(config.openclawAutoMemoryNoveltyThreshold);
|
|
268
|
+
const maxRecentRaw = Number(config.openclawAutoMemoryMaxRecent);
|
|
269
|
+
const threshold = Number.isFinite(thresholdRaw)
|
|
270
|
+
? clamp(thresholdRaw, 0.6, 0.99)
|
|
271
|
+
: DEFAULT_NOVELTY_THRESHOLD;
|
|
272
|
+
const maxRecent = Number.isFinite(maxRecentRaw)
|
|
273
|
+
? Math.floor(clamp(maxRecentRaw, 50, 1000))
|
|
274
|
+
: DEFAULT_MAX_RECENT;
|
|
275
|
+
const enabled = isAutoMemoryDedupeEnabled(config);
|
|
276
|
+
const entries = enabled ? await loadNoveltyCache(maxRecent) : [];
|
|
277
|
+
let dirty = false;
|
|
278
|
+
return {
|
|
279
|
+
inspect(content) {
|
|
280
|
+
if (!enabled)
|
|
281
|
+
return { allow: true, contentHash: null, tokenHashes: [] };
|
|
282
|
+
return inspectNovelty(content, entries, threshold);
|
|
283
|
+
},
|
|
284
|
+
remember(memory, novelty) {
|
|
285
|
+
if (!enabled || !novelty.contentHash || novelty.tokenHashes.length === 0)
|
|
286
|
+
return;
|
|
287
|
+
entries.unshift({
|
|
288
|
+
hash: novelty.contentHash,
|
|
289
|
+
tokenHashes: novelty.tokenHashes,
|
|
290
|
+
title: String(memory.title || "").slice(0, 120),
|
|
291
|
+
category: String(memory.category || "note"),
|
|
292
|
+
createdAt: new Date().toISOString(),
|
|
293
|
+
});
|
|
294
|
+
if (entries.length > maxRecent)
|
|
295
|
+
entries.length = maxRecent;
|
|
296
|
+
dirty = true;
|
|
297
|
+
},
|
|
298
|
+
async flush() {
|
|
299
|
+
if (!enabled || !dirty)
|
|
300
|
+
return;
|
|
301
|
+
await saveNoveltyCache(entries);
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
178
305
|
// ==================== HOOK HANDLERS ====================
|
|
179
306
|
// Skip scanning internal OpenClaw content (boot checks, system prompts, heartbeats)
|
|
180
307
|
const SKIP_PATTERNS = [
|
|
@@ -225,25 +352,38 @@ function handleLlmOutput(event, ctx) {
|
|
|
225
352
|
// Fire and forget
|
|
226
353
|
(async () => {
|
|
227
354
|
try {
|
|
355
|
+
const config = await loadConfig();
|
|
356
|
+
if (!isAutoMemoryEnabled(config))
|
|
357
|
+
return;
|
|
228
358
|
const texts = event.assistantTexts.filter(t => t && t.length >= 30);
|
|
229
359
|
if (!texts.length)
|
|
230
360
|
return;
|
|
231
361
|
const memories = extractMemories(texts);
|
|
232
362
|
if (!memories.length)
|
|
233
363
|
return;
|
|
364
|
+
const noveltyGate = await createNoveltyGate(config);
|
|
234
365
|
let saved = 0;
|
|
366
|
+
let skipped = 0;
|
|
235
367
|
for (const mem of memories) {
|
|
368
|
+
const novelty = noveltyGate.inspect(mem.content);
|
|
369
|
+
if (!novelty.allow) {
|
|
370
|
+
skipped++;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
236
373
|
const r = await callCortex("remember", {
|
|
237
374
|
title: mem.title, content: mem.content, category: mem.category,
|
|
238
375
|
project: ctx.agentId || "openclaw", scope: "global",
|
|
239
376
|
importance: "normal",
|
|
240
377
|
});
|
|
241
|
-
if (r)
|
|
378
|
+
if (r) {
|
|
242
379
|
saved++;
|
|
380
|
+
noveltyGate.remember(mem, novelty);
|
|
381
|
+
}
|
|
243
382
|
}
|
|
383
|
+
await noveltyGate.flush();
|
|
244
384
|
if (saved) {
|
|
245
|
-
console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output`);
|
|
246
|
-
auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, ts: new Date().toISOString() });
|
|
385
|
+
console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output (${skipped} duplicates skipped)`);
|
|
386
|
+
auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, skipped, ts: new Date().toISOString() });
|
|
247
387
|
}
|
|
248
388
|
}
|
|
249
389
|
catch (e) {
|
|
@@ -255,7 +395,7 @@ function handleLlmOutput(event, ctx) {
|
|
|
255
395
|
export default {
|
|
256
396
|
id: "shieldcortex-realtime",
|
|
257
397
|
name: "ShieldCortex Real-time Scanner",
|
|
258
|
-
description: "Real-time defence scanning on LLM inputs
|
|
398
|
+
description: "Real-time defence scanning on LLM inputs with optional memory extraction from outputs",
|
|
259
399
|
version: "__SHIELDCORTEX_VERSION__",
|
|
260
400
|
register(api) {
|
|
261
401
|
api.on("llm_input", handleLlmInput);
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* ShieldCortex Real-time Scanning Plugin for OpenClaw v2026.2.15+
|
|
3
3
|
*
|
|
4
4
|
* Hooks into llm_input/llm_output for real-time defence scanning
|
|
5
|
-
* and memory extraction. All operations are fire-and-forget.
|
|
5
|
+
* and optional memory extraction. All operations are fire-and-forget.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { execFile } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
9
10
|
import fs from "node:fs/promises";
|
|
10
11
|
import { readFileSync } from "node:fs";
|
|
11
12
|
import path from "node:path";
|
|
@@ -34,7 +35,15 @@ type PluginApi = {
|
|
|
34
35
|
|
|
35
36
|
// ==================== CONFIG ====================
|
|
36
37
|
|
|
37
|
-
interface SCConfig {
|
|
38
|
+
interface SCConfig {
|
|
39
|
+
cloudApiKey?: string;
|
|
40
|
+
cloudEndpoint?: string;
|
|
41
|
+
binaryPath?: string;
|
|
42
|
+
openclawAutoMemory?: boolean;
|
|
43
|
+
openclawAutoMemoryDedupe?: boolean;
|
|
44
|
+
openclawAutoMemoryNoveltyThreshold?: number;
|
|
45
|
+
openclawAutoMemoryMaxRecent?: number;
|
|
46
|
+
}
|
|
38
47
|
let _config: SCConfig | null = null;
|
|
39
48
|
let _version = "0.0.0";
|
|
40
49
|
try {
|
|
@@ -50,6 +59,14 @@ async function loadConfig(): Promise<SCConfig> {
|
|
|
50
59
|
return _config!;
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
function isAutoMemoryEnabled(config: SCConfig): boolean {
|
|
63
|
+
return config.openclawAutoMemory === true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isAutoMemoryDedupeEnabled(config: SCConfig): boolean {
|
|
67
|
+
return config.openclawAutoMemoryDedupe !== false;
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
// ==================== SERVER CMD ====================
|
|
54
71
|
|
|
55
72
|
let _serverCmd: string | null = null;
|
|
@@ -133,6 +150,10 @@ function extractUserContent(msgs: unknown[]): string[] {
|
|
|
133
150
|
}
|
|
134
151
|
|
|
135
152
|
const AUDIT_DIR = path.join(homedir(), ".shieldcortex", "audit");
|
|
153
|
+
const NOVELTY_CACHE_FILE = path.join(homedir(), ".shieldcortex", "openclaw-memory-cache.json");
|
|
154
|
+
const DEFAULT_NOVELTY_THRESHOLD = 0.88;
|
|
155
|
+
const DEFAULT_MAX_RECENT = 300;
|
|
156
|
+
const MIN_NOVELTY_CHARS = 40;
|
|
136
157
|
|
|
137
158
|
async function auditLog(entry: Record<string, unknown>) {
|
|
138
159
|
try {
|
|
@@ -157,6 +178,147 @@ async function cloudSync(threat: Record<string, unknown>) {
|
|
|
157
178
|
} catch {}
|
|
158
179
|
}
|
|
159
180
|
|
|
181
|
+
type NoveltyEntry = {
|
|
182
|
+
hash: string;
|
|
183
|
+
tokenHashes: string[];
|
|
184
|
+
title: string;
|
|
185
|
+
category: string;
|
|
186
|
+
createdAt: string;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
function normalizeMemoryText(text: string): string {
|
|
190
|
+
return String(text || "")
|
|
191
|
+
.toLowerCase()
|
|
192
|
+
.replace(/[`"'\\]/g, " ")
|
|
193
|
+
.replace(/https?:\/\/\S+/g, " ")
|
|
194
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
195
|
+
.replace(/\s+/g, " ")
|
|
196
|
+
.trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function hashToken(token: string): string {
|
|
200
|
+
return createHash("sha1").update(token).digest("hex").slice(0, 12);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildTokenHashes(normalized: string): string[] {
|
|
204
|
+
const words = normalized.split(" ").filter((w) => w.length >= 3);
|
|
205
|
+
const set = new Set<string>();
|
|
206
|
+
|
|
207
|
+
for (let i = 0; i < words.length; i++) {
|
|
208
|
+
set.add(hashToken(words[i]));
|
|
209
|
+
if (i < words.length - 1) set.add(hashToken(`${words[i]}_${words[i + 1]}`));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return Array.from(set).slice(0, 200);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
|
216
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
217
|
+
let intersection = 0;
|
|
218
|
+
for (const item of a) {
|
|
219
|
+
if (b.has(item)) intersection++;
|
|
220
|
+
}
|
|
221
|
+
const union = a.size + b.size - intersection;
|
|
222
|
+
return union === 0 ? 0 : intersection / union;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function clamp(value: number, min: number, max: number): number {
|
|
226
|
+
return Math.max(min, Math.min(max, value));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function loadNoveltyCache(maxRecent: number): Promise<NoveltyEntry[]> {
|
|
230
|
+
try {
|
|
231
|
+
const raw = JSON.parse(await fs.readFile(NOVELTY_CACHE_FILE, "utf-8"));
|
|
232
|
+
if (!Array.isArray(raw)) return [];
|
|
233
|
+
return raw
|
|
234
|
+
.filter((entry) => entry && typeof entry.hash === "string" && Array.isArray(entry.tokenHashes))
|
|
235
|
+
.slice(0, maxRecent) as NoveltyEntry[];
|
|
236
|
+
} catch {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function saveNoveltyCache(entries: NoveltyEntry[]): Promise<void> {
|
|
242
|
+
await fs.mkdir(path.dirname(NOVELTY_CACHE_FILE), { recursive: true });
|
|
243
|
+
await fs.writeFile(NOVELTY_CACHE_FILE, JSON.stringify(entries, null, 2) + "\n", "utf-8");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function inspectNovelty(content: string, entries: NoveltyEntry[], threshold: number): {
|
|
247
|
+
allow: boolean;
|
|
248
|
+
contentHash: string | null;
|
|
249
|
+
tokenHashes: string[];
|
|
250
|
+
reason?: string;
|
|
251
|
+
} {
|
|
252
|
+
const normalized = normalizeMemoryText(content);
|
|
253
|
+
if (normalized.length < MIN_NOVELTY_CHARS) {
|
|
254
|
+
return { allow: true, contentHash: null, tokenHashes: [] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const contentHash = createHash("sha256").update(normalized).digest("hex").slice(0, 24);
|
|
258
|
+
if (entries.some((entry) => entry.hash === contentHash)) {
|
|
259
|
+
return { allow: false, contentHash, tokenHashes: [], reason: "exact duplicate" };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const tokenHashes = buildTokenHashes(normalized);
|
|
263
|
+
const currentSet = new Set(tokenHashes);
|
|
264
|
+
|
|
265
|
+
for (const entry of entries) {
|
|
266
|
+
const score = jaccardSimilarity(currentSet, new Set(entry.tokenHashes || []));
|
|
267
|
+
if (score >= threshold) {
|
|
268
|
+
return {
|
|
269
|
+
allow: false,
|
|
270
|
+
contentHash,
|
|
271
|
+
tokenHashes,
|
|
272
|
+
reason: `near duplicate (similarity ${score.toFixed(2)})`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { allow: true, contentHash, tokenHashes };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function createNoveltyGate(config: SCConfig): Promise<{
|
|
281
|
+
inspect: (content: string) => { allow: boolean; contentHash: string | null; tokenHashes: string[]; reason?: string };
|
|
282
|
+
remember: (memory: { title: string; category: string }, novelty: { contentHash: string | null; tokenHashes: string[] }) => void;
|
|
283
|
+
flush: () => Promise<void>;
|
|
284
|
+
}> {
|
|
285
|
+
const thresholdRaw = Number(config.openclawAutoMemoryNoveltyThreshold);
|
|
286
|
+
const maxRecentRaw = Number(config.openclawAutoMemoryMaxRecent);
|
|
287
|
+
const threshold = Number.isFinite(thresholdRaw)
|
|
288
|
+
? clamp(thresholdRaw, 0.6, 0.99)
|
|
289
|
+
: DEFAULT_NOVELTY_THRESHOLD;
|
|
290
|
+
const maxRecent = Number.isFinite(maxRecentRaw)
|
|
291
|
+
? Math.floor(clamp(maxRecentRaw, 50, 1000))
|
|
292
|
+
: DEFAULT_MAX_RECENT;
|
|
293
|
+
|
|
294
|
+
const enabled = isAutoMemoryDedupeEnabled(config);
|
|
295
|
+
const entries = enabled ? await loadNoveltyCache(maxRecent) : [];
|
|
296
|
+
let dirty = false;
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
inspect(content: string) {
|
|
300
|
+
if (!enabled) return { allow: true, contentHash: null, tokenHashes: [] };
|
|
301
|
+
return inspectNovelty(content, entries, threshold);
|
|
302
|
+
},
|
|
303
|
+
remember(memory, novelty) {
|
|
304
|
+
if (!enabled || !novelty.contentHash || novelty.tokenHashes.length === 0) return;
|
|
305
|
+
entries.unshift({
|
|
306
|
+
hash: novelty.contentHash,
|
|
307
|
+
tokenHashes: novelty.tokenHashes,
|
|
308
|
+
title: String(memory.title || "").slice(0, 120),
|
|
309
|
+
category: String(memory.category || "note"),
|
|
310
|
+
createdAt: new Date().toISOString(),
|
|
311
|
+
});
|
|
312
|
+
if (entries.length > maxRecent) entries.length = maxRecent;
|
|
313
|
+
dirty = true;
|
|
314
|
+
},
|
|
315
|
+
async flush() {
|
|
316
|
+
if (!enabled || !dirty) return;
|
|
317
|
+
await saveNoveltyCache(entries);
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
160
322
|
// ==================== HOOK HANDLERS ====================
|
|
161
323
|
|
|
162
324
|
// Skip scanning internal OpenClaw content (boot checks, system prompts, heartbeats)
|
|
@@ -208,23 +370,38 @@ function handleLlmOutput(event: LlmOutputEvent, ctx: AgentCtx): void {
|
|
|
208
370
|
// Fire and forget
|
|
209
371
|
(async () => {
|
|
210
372
|
try {
|
|
373
|
+
const config = await loadConfig();
|
|
374
|
+
if (!isAutoMemoryEnabled(config)) return;
|
|
375
|
+
|
|
211
376
|
const texts = event.assistantTexts.filter(t => t && t.length >= 30);
|
|
212
377
|
if (!texts.length) return;
|
|
213
378
|
const memories = extractMemories(texts);
|
|
214
379
|
if (!memories.length) return;
|
|
215
380
|
|
|
381
|
+
const noveltyGate = await createNoveltyGate(config);
|
|
216
382
|
let saved = 0;
|
|
383
|
+
let skipped = 0;
|
|
217
384
|
for (const mem of memories) {
|
|
385
|
+
const novelty = noveltyGate.inspect(mem.content);
|
|
386
|
+
if (!novelty.allow) {
|
|
387
|
+
skipped++;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
218
391
|
const r = await callCortex("remember", {
|
|
219
392
|
title: mem.title, content: mem.content, category: mem.category,
|
|
220
393
|
project: ctx.agentId || "openclaw", scope: "global",
|
|
221
394
|
importance: "normal", tags: "auto-extracted,realtime-plugin,llm-output",
|
|
222
395
|
});
|
|
223
|
-
if (r)
|
|
396
|
+
if (r) {
|
|
397
|
+
saved++;
|
|
398
|
+
noveltyGate.remember(mem, novelty);
|
|
399
|
+
}
|
|
224
400
|
}
|
|
401
|
+
await noveltyGate.flush();
|
|
225
402
|
if (saved) {
|
|
226
|
-
console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output`);
|
|
227
|
-
auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, ts: new Date().toISOString() });
|
|
403
|
+
console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output (${skipped} duplicates skipped)`);
|
|
404
|
+
auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, skipped, ts: new Date().toISOString() });
|
|
228
405
|
}
|
|
229
406
|
} catch (e) {
|
|
230
407
|
console.error("[shieldcortex] llm_output error:", e instanceof Error ? e.message : String(e));
|
|
@@ -237,7 +414,7 @@ function handleLlmOutput(event: LlmOutputEvent, ctx: AgentCtx): void {
|
|
|
237
414
|
export default {
|
|
238
415
|
id: "shieldcortex-realtime",
|
|
239
416
|
name: "ShieldCortex Real-time Scanner",
|
|
240
|
-
description: "Real-time defence scanning on LLM inputs
|
|
417
|
+
description: "Real-time defence scanning on LLM inputs with optional memory extraction from outputs",
|
|
241
418
|
version: _version,
|
|
242
419
|
|
|
243
420
|
register(api: PluginApi) {
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -3,12 +3,46 @@
|
|
|
3
3
|
* Postinstall script - prints setup instructions after global install.
|
|
4
4
|
* Does NOT auto-run setup (can fail in CI, user might not have Claude Code).
|
|
5
5
|
*/
|
|
6
|
+
import { existsSync } from 'fs';
|
|
7
|
+
import { join, dirname } from 'path';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
import { spawnSync } from 'child_process';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
6
11
|
|
|
7
12
|
// Only show message for global installs (not local dev or CI)
|
|
8
13
|
const isGlobal = process.env.npm_config_global === 'true';
|
|
9
14
|
const isCI = process.env.CI === 'true' || process.env.CONTINUOUS_INTEGRATION === 'true';
|
|
15
|
+
const skipAutoOpenClaw = process.env.SHIELDCORTEX_SKIP_AUTO_OPENCLAW === '1';
|
|
16
|
+
|
|
17
|
+
function shouldRefreshOpenClaw() {
|
|
18
|
+
const home = homedir();
|
|
19
|
+
const openclawDir = join(home, '.openclaw');
|
|
20
|
+
const knownHook = join(openclawDir, 'hooks', 'cortex-memory');
|
|
21
|
+
const knownPlugin = join(openclawDir, 'extensions', 'shieldcortex-realtime');
|
|
22
|
+
return existsSync(openclawDir) || existsSync(knownHook) || existsSync(knownPlugin);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function refreshOpenClawInstall() {
|
|
26
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
27
|
+
const __dirname = dirname(__filename);
|
|
28
|
+
const cliPath = join(__dirname, '..', 'dist', 'index.js');
|
|
29
|
+
|
|
30
|
+
if (!existsSync(cliPath)) return;
|
|
31
|
+
const result = spawnSync(process.execPath, [cliPath, 'openclaw', 'install'], {
|
|
32
|
+
stdio: 'inherit',
|
|
33
|
+
env: process.env,
|
|
34
|
+
});
|
|
35
|
+
if (result.status !== 0) {
|
|
36
|
+
console.warn('[shieldcortex] OpenClaw auto-refresh skipped (non-fatal).');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
10
39
|
|
|
11
40
|
if (isGlobal && !isCI) {
|
|
41
|
+
if (!skipAutoOpenClaw && shouldRefreshOpenClaw()) {
|
|
42
|
+
console.log('\n[shieldcortex] OpenClaw detected. Refreshing hook/plugin to latest version...');
|
|
43
|
+
refreshOpenClawInstall();
|
|
44
|
+
}
|
|
45
|
+
|
|
12
46
|
console.log('');
|
|
13
47
|
console.log('\x1b[36m╭───────────────────────────────────────────────────────╮\x1b[0m');
|
|
14
48
|
console.log('\x1b[36m│\x1b[0m \x1b[1mShieldCortex installed!\x1b[0m \x1b[36m│\x1b[0m');
|