@drakon-systems/shieldcortex-realtime 3.4.4

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 ADDED
@@ -0,0 +1,130 @@
1
+ # @drakon-systems/shieldcortex-realtime
2
+
3
+ OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.
4
+
5
+ ## What it does
6
+
7
+ | Hook | Action |
8
+ |------|--------|
9
+ | `llm_input` | Scans prompts and history through the ShieldCortex defence pipeline. Threats are logged to audit and can forward to ShieldCortex Cloud. |
10
+ | `llm_output` | Extracts high-signal memories from assistant replies and writes them into ShieldCortex with novelty filtering and dedupe. |
11
+
12
+ The plugin is intentionally fire-and-forget: it should not stall the OpenClaw turn loop if ShieldCortex is unavailable.
13
+
14
+ ## Installation
15
+
16
+ ### 1. Install ShieldCortex
17
+
18
+ This plugin resolves the main `shieldcortex` package at runtime, so the CLI must also be installed somewhere the machine can reach.
19
+
20
+ ```bash
21
+ npm install -g shieldcortex
22
+ ```
23
+
24
+ If `shieldcortex` is not on `PATH`, set `binaryPath` in the plugin config.
25
+
26
+ ### 2. Install the plugin
27
+
28
+ ```bash
29
+ openclaw plugins install @drakon-systems/shieldcortex-realtime
30
+ ```
31
+
32
+ If you also want the companion session hook, install it from the main package:
33
+
34
+ ```bash
35
+ openclaw hooks install shieldcortex
36
+ ```
37
+
38
+ Restart OpenClaw after installing:
39
+
40
+ ```bash
41
+ openclaw gateway restart
42
+ ```
43
+
44
+ ### Local development
45
+
46
+ From the monorepo root, you can link the working plugin directory directly:
47
+
48
+ ```bash
49
+ openclaw plugins install --link /path/to/ShieldCortex/plugins/openclaw
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ The plugin reads config from `plugins.entries.shieldcortex-realtime.config` in your OpenClaw config and merges it over `~/.shieldcortex/config.json`.
55
+
56
+ Example:
57
+
58
+ ```json
59
+ {
60
+ "plugins": {
61
+ "entries": {
62
+ "shieldcortex-realtime": {
63
+ "enabled": true,
64
+ "config": {
65
+ "binaryPath": "/usr/local/bin/shieldcortex",
66
+ "openclawAutoMemory": true,
67
+ "openclawAutoMemoryDedupe": true,
68
+ "openclawAutoMemoryNoveltyThreshold": 0.88,
69
+ "openclawAutoMemoryMaxRecent": 300
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ ```
76
+
77
+ Supported plugin config keys:
78
+
79
+ - `binaryPath`: absolute path to the `shieldcortex` binary
80
+ - `cloudApiKey`: optional ShieldCortex Cloud API key for realtime threat forwarding
81
+ - `cloudBaseUrl`: optional API base URL override
82
+ - `openclawAutoMemory`: enable or disable output memory extraction
83
+ - `openclawAutoMemoryDedupe`: enable or disable duplicate suppression
84
+ - `openclawAutoMemoryNoveltyThreshold`: dedupe similarity threshold, `0.6` to `0.99`
85
+ - `openclawAutoMemoryMaxRecent`: dedupe cache size, `50` to `1000`
86
+
87
+ ## Auto-memory
88
+
89
+ Auto-memory extraction is enabled when `openclawAutoMemory` is `true`. It complements your existing memory setup with deduplication to avoid noisy repeats.
90
+
91
+ You can manage the same settings through ShieldCortex itself:
92
+
93
+ ```bash
94
+ shieldcortex config --openclaw-auto-memory true
95
+ shieldcortex config --openclaw-auto-memory false
96
+ ```
97
+
98
+ Or by editing `~/.shieldcortex/config.json`:
99
+
100
+ ```json
101
+ {
102
+ "openclawAutoMemory": true,
103
+ "openclawAutoMemoryDedupe": true,
104
+ "openclawAutoMemoryNoveltyThreshold": 0.88,
105
+ "openclawAutoMemoryMaxRecent": 300
106
+ }
107
+ ```
108
+
109
+ ## Cloud forwarding
110
+
111
+ Threat forwarding is optional. Configure it in ShieldCortex:
112
+
113
+ ```json
114
+ {
115
+ "cloudApiKey": "sc_...",
116
+ "cloudBaseUrl": "https://api.shieldcortex.ai"
117
+ }
118
+ ```
119
+
120
+ Or in the plugin entry config if you want plugin-specific overrides.
121
+
122
+ ## Audit logs
123
+
124
+ Realtime events are written to:
125
+
126
+ ```text
127
+ ~/.shieldcortex/audit/realtime-YYYY-MM-DD.jsonl
128
+ ```
129
+
130
+ Each line is a JSON object with input-scan, threat, and output-memory activity.
package/index.ts ADDED
@@ -0,0 +1,682 @@
1
+ /**
2
+ * ShieldCortex Real-time Scanning Plugin for OpenClaw v2026.2.15+
3
+ *
4
+ * Hooks into llm_input/llm_output for real-time defence scanning
5
+ * and optional memory extraction. All operations are fire-and-forget.
6
+ */
7
+
8
+ import { execFileSync } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import fs from "node:fs/promises";
11
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+
16
+ // ==================== RESILIENT RUNTIME LOADER ====================
17
+ // Resolves runtime.mjs from multiple locations so the plugin works both
18
+ // inside the npm package tree AND when copied to ~/.openclaw/extensions/
19
+
20
+ type OpenClawRuntime = {
21
+ callCortex: (tool: string, args?: Record<string, string>) => Promise<string | null>;
22
+ isOpenClawAutoMemoryEnabled: (config: any) => boolean;
23
+ loadShieldConfig: () => Promise<any>;
24
+ };
25
+
26
+ let runtimePromise: Promise<OpenClawRuntime> | null = null;
27
+
28
+ function addRuntimeCandidate(candidates: Set<string>, packageRoot: string) {
29
+ const runtimePath = path.join(packageRoot, "hooks", "openclaw", "cortex-memory", "runtime.mjs");
30
+ if (existsSync(runtimePath)) {
31
+ candidates.add(pathToFileURL(runtimePath).href);
32
+ }
33
+ }
34
+
35
+ function addAncestorCandidates(candidates: Set<string>, startPath: string) {
36
+ let current = path.resolve(startPath);
37
+ let previous = "";
38
+ for (let i = 0; i < 6 && current !== previous; i++) {
39
+ addRuntimeCandidate(candidates, current);
40
+ previous = current;
41
+ current = path.dirname(current);
42
+ }
43
+ }
44
+
45
+ function collectRuntimeCandidates(): string[] {
46
+ const candidates = new Set<string>();
47
+
48
+ // 1. Relative path (works when running from within npm package tree)
49
+ candidates.add(new URL("../../hooks/openclaw/cortex-memory/runtime.mjs", import.meta.url).href);
50
+
51
+ // 2. Environment variable override
52
+ if (process.env.SHIELDCORTEX_ROOT) {
53
+ addRuntimeCandidate(candidates, process.env.SHIELDCORTEX_ROOT);
54
+ }
55
+
56
+ // 3. Walk up from current file location
57
+ addAncestorCandidates(candidates, path.dirname(fileURLToPath(import.meta.url)));
58
+
59
+ // 4. Find via shieldcortex binary
60
+ try {
61
+ const bin = execFileSync("which", ["shieldcortex"], {
62
+ encoding: "utf-8",
63
+ timeout: 3000,
64
+ }).trim();
65
+ if (bin) addAncestorCandidates(candidates, realpathSync(bin));
66
+ } catch {}
67
+
68
+ // 5. npm global root
69
+ try {
70
+ const npmRoot = execFileSync("npm", ["root", "-g"], {
71
+ encoding: "utf-8",
72
+ timeout: 3000,
73
+ }).trim();
74
+ if (npmRoot) addRuntimeCandidate(candidates, path.join(npmRoot, "shieldcortex"));
75
+ } catch {}
76
+
77
+ // 6. npm prefix
78
+ try {
79
+ const prefix = execFileSync("npm", ["config", "get", "prefix"], {
80
+ encoding: "utf-8",
81
+ timeout: 3000,
82
+ }).trim();
83
+ if (prefix) addRuntimeCandidate(candidates, path.join(prefix, "lib", "node_modules", "shieldcortex"));
84
+ } catch {}
85
+
86
+ // 7. Common global install paths
87
+ for (const root of [
88
+ "/usr/lib/node_modules/shieldcortex",
89
+ "/usr/local/lib/node_modules/shieldcortex",
90
+ "/opt/homebrew/lib/node_modules/shieldcortex",
91
+ path.join(homedir(), ".npm-global", "lib", "node_modules", "shieldcortex"),
92
+ ]) {
93
+ addRuntimeCandidate(candidates, root);
94
+ }
95
+
96
+ return [...candidates];
97
+ }
98
+
99
+ async function getRuntime(): Promise<OpenClawRuntime> {
100
+ if (!runtimePromise) {
101
+ runtimePromise = (async () => {
102
+ const tried: string[] = [];
103
+ let lastError: unknown = null;
104
+
105
+ for (const candidate of collectRuntimeCandidates()) {
106
+ tried.push(candidate);
107
+ try {
108
+ const mod = await import(candidate);
109
+ if (typeof mod.createOpenClawRuntime === "function") {
110
+ return mod.createOpenClawRuntime({ logPrefix: "[shieldcortex]" }) as OpenClawRuntime;
111
+ }
112
+ } catch (error) {
113
+ lastError = error;
114
+ }
115
+ }
116
+
117
+ const detail = lastError instanceof Error ? lastError.message : String(lastError ?? "unknown error");
118
+ throw new Error(`Could not load OpenClaw runtime. Tried: ${tried.join(", ")}. Last error: ${detail}`);
119
+ })();
120
+ }
121
+
122
+ return runtimePromise;
123
+ }
124
+
125
+ type LlmInputEvent = {
126
+ runId: string; sessionId: string; provider: string; model: string;
127
+ systemPrompt?: string; prompt: string; historyMessages: unknown[]; imagesCount: number;
128
+ };
129
+ type LlmOutputEvent = {
130
+ runId: string; sessionId: string; provider: string; model: string;
131
+ assistantTexts: string[]; lastAssistant?: unknown;
132
+ usage?: { input?: number; output?: number; total?: number };
133
+ };
134
+ type AgentCtx = {
135
+ agentId?: string; sessionKey?: string; sessionId?: string;
136
+ workspaceDir?: string; messageProvider?: string;
137
+ };
138
+ type PluginApi = {
139
+ id: string; name: string; logger: { info: (m: string) => void };
140
+ on: (hook: string, handler: (...args: any[]) => any) => void;
141
+ [k: string]: any;
142
+ };
143
+
144
+ // ==================== CONFIG ====================
145
+
146
+ interface SCConfig {
147
+ cloudApiKey?: string;
148
+ cloudBaseUrl?: string;
149
+ binaryPath?: string;
150
+ openclawAutoMemory?: boolean;
151
+ openclawAutoMemoryDedupe?: boolean;
152
+ openclawAutoMemoryNoveltyThreshold?: number;
153
+ openclawAutoMemoryMaxRecent?: number;
154
+ }
155
+
156
+ const PLUGIN_ID = "shieldcortex-realtime";
157
+ const PLUGIN_PACKAGE_NAME = "@drakon-systems/shieldcortex-realtime";
158
+ const PLUGIN_CONFIG_UI_HINTS = {
159
+ binaryPath: {
160
+ label: "ShieldCortex Binary Path",
161
+ help: "Optional absolute path to the shieldcortex CLI when it is not on PATH.",
162
+ placeholder: "/usr/local/bin/shieldcortex",
163
+ advanced: true,
164
+ },
165
+ cloudApiKey: {
166
+ label: "Cloud API Key",
167
+ help: "Optional ShieldCortex Cloud API key used for realtime threat forwarding.",
168
+ sensitive: true,
169
+ placeholder: "sc_...",
170
+ },
171
+ cloudBaseUrl: {
172
+ label: "Cloud Base URL",
173
+ help: "Override the ShieldCortex Cloud API base URL if you use a self-hosted or staging endpoint.",
174
+ placeholder: "https://api.shieldcortex.ai",
175
+ advanced: true,
176
+ },
177
+ openclawAutoMemory: {
178
+ label: "Auto Memory Extraction",
179
+ help: "Extract high-signal decisions and learnings from LLM output into ShieldCortex memory.",
180
+ },
181
+ openclawAutoMemoryDedupe: {
182
+ label: "Dedupe Auto Memory",
183
+ help: "Skip near-duplicate memories before they are written to ShieldCortex.",
184
+ advanced: true,
185
+ },
186
+ openclawAutoMemoryNoveltyThreshold: {
187
+ label: "Novelty Threshold",
188
+ help: "Similarity threshold for duplicate suppression. Higher values keep more memories.",
189
+ advanced: true,
190
+ },
191
+ openclawAutoMemoryMaxRecent: {
192
+ label: "Recent Memory Cache Size",
193
+ help: "How many recent extracted memories to keep in the dedupe cache.",
194
+ advanced: true,
195
+ },
196
+ } as const;
197
+
198
+ const PLUGIN_CONFIG_JSON_SCHEMA = {
199
+ type: "object",
200
+ additionalProperties: false,
201
+ properties: {
202
+ enabled: { type: "boolean" },
203
+ binaryPath: { type: "string" },
204
+ cloudApiKey: { type: "string" },
205
+ cloudBaseUrl: { type: "string" },
206
+ openclawAutoMemory: { type: "boolean" },
207
+ openclawAutoMemoryDedupe: { type: "boolean" },
208
+ openclawAutoMemoryNoveltyThreshold: { type: "number", minimum: 0.6, maximum: 0.99 },
209
+ openclawAutoMemoryMaxRecent: { type: "integer", minimum: 50, maximum: 1000 },
210
+ },
211
+ };
212
+
213
+ let _config: SCConfig | null = null;
214
+ let _configOverride: SCConfig | null = null;
215
+ let _version = "0.0.0";
216
+ try {
217
+ for (const packageUrl of [
218
+ new URL("./package.json", import.meta.url),
219
+ new URL("../../package.json", import.meta.url),
220
+ ]) {
221
+ try {
222
+ const pkg = JSON.parse(readFileSync(packageUrl, "utf-8"));
223
+ if (typeof pkg.version === "string" && pkg.version.trim()) {
224
+ _version = pkg.version;
225
+ break;
226
+ }
227
+ } catch {
228
+ // try the next candidate
229
+ }
230
+ }
231
+ } catch { /* fallback */ }
232
+
233
+ function normaliseConfig(raw: unknown): SCConfig {
234
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
235
+
236
+ const value = raw as Record<string, unknown>;
237
+ const config: SCConfig = {};
238
+
239
+ if (typeof value.cloudApiKey === "string" && value.cloudApiKey.trim()) {
240
+ config.cloudApiKey = value.cloudApiKey.trim();
241
+ }
242
+ if (typeof value.cloudBaseUrl === "string" && value.cloudBaseUrl.trim()) {
243
+ config.cloudBaseUrl = value.cloudBaseUrl.trim();
244
+ }
245
+ if (typeof value.binaryPath === "string" && value.binaryPath.trim()) {
246
+ config.binaryPath = value.binaryPath.trim();
247
+ }
248
+ if (typeof value.openclawAutoMemory === "boolean") {
249
+ config.openclawAutoMemory = value.openclawAutoMemory;
250
+ }
251
+ if (typeof value.openclawAutoMemoryDedupe === "boolean") {
252
+ config.openclawAutoMemoryDedupe = value.openclawAutoMemoryDedupe;
253
+ }
254
+ if (typeof value.openclawAutoMemoryNoveltyThreshold === "number" && !Number.isNaN(value.openclawAutoMemoryNoveltyThreshold)) {
255
+ config.openclawAutoMemoryNoveltyThreshold = clamp(value.openclawAutoMemoryNoveltyThreshold, 0.6, 0.99);
256
+ }
257
+ if (typeof value.openclawAutoMemoryMaxRecent === "number" && !Number.isNaN(value.openclawAutoMemoryMaxRecent)) {
258
+ config.openclawAutoMemoryMaxRecent = Math.floor(clamp(value.openclawAutoMemoryMaxRecent, 50, 1000));
259
+ }
260
+
261
+ return config;
262
+ }
263
+
264
+ function extractPluginConfig(rootConfig: unknown): SCConfig {
265
+ if (!rootConfig || typeof rootConfig !== "object" || Array.isArray(rootConfig)) return {};
266
+ const entries = (rootConfig as {
267
+ plugins?: {
268
+ entries?: Record<string, { config?: unknown } | undefined>;
269
+ };
270
+ }).plugins?.entries;
271
+
272
+ const pluginConfig =
273
+ entries?.[PLUGIN_ID]?.config ??
274
+ entries?.[PLUGIN_PACKAGE_NAME]?.config;
275
+
276
+ return normaliseConfig(pluginConfig);
277
+ }
278
+
279
+ function applyPluginConfigOverride(api: PluginApi): void {
280
+ const runtimeConfig = typeof api.runtime?.config?.loadConfig === "function"
281
+ ? api.runtime.config.loadConfig()
282
+ : api.config;
283
+ const pluginConfig = extractPluginConfig(runtimeConfig);
284
+ if (Object.keys(pluginConfig).length === 0) return;
285
+ _configOverride = {
286
+ ...(_configOverride ?? {}),
287
+ ...pluginConfig,
288
+ };
289
+ if (_config) {
290
+ _config = { ..._config, ...pluginConfig };
291
+ }
292
+ }
293
+
294
+ async function loadConfig(): Promise<SCConfig> {
295
+ if (_config) return _config;
296
+ const shieldConfig = normaliseConfig(await (await getRuntime()).loadShieldConfig());
297
+ _config = {
298
+ ...shieldConfig,
299
+ ...(_configOverride ?? {}),
300
+ };
301
+ return _config;
302
+ }
303
+
304
+ function isAutoMemoryEnabled(config: SCConfig): boolean {
305
+ return config.openclawAutoMemory === true;
306
+ }
307
+
308
+ function isAutoMemoryDedupeEnabled(config: SCConfig): boolean {
309
+ return config.openclawAutoMemoryDedupe !== false;
310
+ }
311
+
312
+ async function callCortex(tool: string, args: Record<string, string> = {}): Promise<string | null> {
313
+ return (await getRuntime()).callCortex(tool, args);
314
+ }
315
+
316
+ // ==================== REMOTE SCANNING ====================
317
+
318
+ async function scanRealtimeContent(text: string): Promise<{ clean: boolean; summary: string }> {
319
+ const response = await callCortex("scan_tool_response", {
320
+ toolName: "openclaw-realtime",
321
+ content: text,
322
+ mode: "advisory",
323
+ });
324
+
325
+ if (!response) {
326
+ return { clean: true, summary: "scan unavailable" };
327
+ }
328
+
329
+ const cleanMatch = response.match(/\*\*Clean:\*\*\s*(Yes|No)/i);
330
+ const riskMatch = response.match(/\*\*Risk Level:\*\*\s*([A-Za-z]+)/i);
331
+ const detectionsMatch = response.match(/\*\*Detections:\*\*\s*(\d+)/i);
332
+
333
+ const clean = cleanMatch ? /yes/i.test(cleanMatch[1]) : true;
334
+ const risk = riskMatch?.[1] ?? "unknown";
335
+ const detections = detectionsMatch?.[1];
336
+ const summary = detections ? `${risk} (${detections} detections)` : risk;
337
+
338
+ return { clean, summary };
339
+ }
340
+
341
+ // ==================== CONTENT PATTERNS ====================
342
+
343
+ const PATTERNS: Record<string, RegExp[]> = {
344
+ architecture: [/\b(?:architecture|designed|structured)\b.*?(?:uses?|is|with)\b/i, /\b(?:decided?\s+to|going\s+with|chose)\b/i],
345
+ error: [/\b(?:fixed|resolved|solved)\s+(?:by|with|using)\b/i, /\b(?:solution|fix|root\s*cause)\s+(?:was|is)\b/i],
346
+ learning: [/\b(?:learned|discovered|turns?\s+out|figured\s+out|realized)\b/i],
347
+ preference: [
348
+ /\b(?:I|we|you\s+should)\s+(?:always|never)\b/i,
349
+ /\b(?:always\s+use|never\s+use|never\s+commit)\b/i,
350
+ /\bprefer(?:\s+to)?\s+\w+/i,
351
+ /\bshould\s+always\b/i,
352
+ ],
353
+ note: [/\b(?:important|remember|key\s+point)\s*:/i],
354
+ };
355
+
356
+ function extractMemories(texts: string[]): Array<{ title: string; content: string; category: string }> {
357
+ const out: Array<{ title: string; content: string; category: string }> = [];
358
+ const seen = new Set<string>();
359
+ for (const text of texts) {
360
+ if (text.length < 30) continue;
361
+ for (const [cat, pats] of Object.entries(PATTERNS)) {
362
+ if (pats.some(p => p.test(text))) {
363
+ const title = text.slice(0, 80).replace(/["\n]/g, " ").trim();
364
+ if (!seen.has(title)) { seen.add(title); out.push({ title, content: text.slice(0, 500), category: cat }); }
365
+ break;
366
+ }
367
+ if (out.length >= 3) break;
368
+ }
369
+ if (out.length >= 3) break;
370
+ }
371
+ return out;
372
+ }
373
+
374
+ // ==================== HELPERS ====================
375
+
376
+ function extractUserContent(msgs: unknown[]): string[] {
377
+ const out: string[] = [];
378
+ for (const msg of msgs) {
379
+ if (!msg || typeof msg !== "object") continue;
380
+ const m = msg as any;
381
+ if (m.role !== "user") continue;
382
+ if (typeof m.content === "string") out.push(m.content);
383
+ else if (Array.isArray(m.content)) for (const b of m.content) if (b?.type === "text") out.push(b.text);
384
+ }
385
+ return out;
386
+ }
387
+
388
+ const AUDIT_DIR = path.join(homedir(), ".shieldcortex", "audit");
389
+ const NOVELTY_CACHE_FILE = path.join(homedir(), ".shieldcortex", "openclaw-memory-cache.json");
390
+ const DEFAULT_NOVELTY_THRESHOLD = 0.88;
391
+ const DEFAULT_MAX_RECENT = 300;
392
+ const MIN_NOVELTY_CHARS = 40;
393
+
394
+ async function auditLog(entry: Record<string, unknown>) {
395
+ try {
396
+ await fs.mkdir(AUDIT_DIR, { recursive: true });
397
+ await fs.appendFile(
398
+ path.join(AUDIT_DIR, `realtime-${new Date().toISOString().slice(0, 10)}.jsonl`),
399
+ JSON.stringify(entry) + "\n",
400
+ );
401
+ } catch {}
402
+ }
403
+
404
+ async function cloudSync(threat: Record<string, unknown>) {
405
+ const cfg = await loadConfig();
406
+ if (!cfg.cloudApiKey) return;
407
+ try {
408
+ await fetch(`${cfg.cloudBaseUrl || "https://api.shieldcortex.ai"}/v1/threats`, {
409
+ method: "POST",
410
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.cloudApiKey}` },
411
+ body: JSON.stringify(threat),
412
+ signal: AbortSignal.timeout(5000),
413
+ });
414
+ } catch {}
415
+ }
416
+
417
+ type NoveltyEntry = {
418
+ hash: string;
419
+ tokenHashes: string[];
420
+ title: string;
421
+ category: string;
422
+ createdAt: string;
423
+ };
424
+
425
+ function normalizeMemoryText(text: string): string {
426
+ return String(text || "")
427
+ .toLowerCase()
428
+ .replace(/[`"'\\]/g, " ")
429
+ .replace(/https?:\/\/\S+/g, " ")
430
+ .replace(/[^a-z0-9\s]/g, " ")
431
+ .replace(/\s+/g, " ")
432
+ .trim();
433
+ }
434
+
435
+ function hashToken(token: string): string {
436
+ return createHash("sha1").update(token).digest("hex").slice(0, 12);
437
+ }
438
+
439
+ function buildTokenHashes(normalized: string): string[] {
440
+ const words = normalized.split(" ").filter((w) => w.length >= 3);
441
+ const set = new Set<string>();
442
+
443
+ for (let i = 0; i < words.length; i++) {
444
+ set.add(hashToken(words[i]));
445
+ if (i < words.length - 1) set.add(hashToken(`${words[i]}_${words[i + 1]}`));
446
+ }
447
+
448
+ return Array.from(set).slice(0, 200);
449
+ }
450
+
451
+ function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
452
+ if (a.size === 0 || b.size === 0) return 0;
453
+ let intersection = 0;
454
+ for (const item of a) {
455
+ if (b.has(item)) intersection++;
456
+ }
457
+ const union = a.size + b.size - intersection;
458
+ return union === 0 ? 0 : intersection / union;
459
+ }
460
+
461
+ function clamp(value: number, min: number, max: number): number {
462
+ return Math.max(min, Math.min(max, value));
463
+ }
464
+
465
+ async function loadNoveltyCache(maxRecent: number): Promise<NoveltyEntry[]> {
466
+ try {
467
+ const raw = JSON.parse(await fs.readFile(NOVELTY_CACHE_FILE, "utf-8"));
468
+ if (!Array.isArray(raw)) return [];
469
+ return raw
470
+ .filter((entry) => entry && typeof entry.hash === "string" && Array.isArray(entry.tokenHashes))
471
+ .slice(0, maxRecent) as NoveltyEntry[];
472
+ } catch {
473
+ return [];
474
+ }
475
+ }
476
+
477
+ async function saveNoveltyCache(entries: NoveltyEntry[]): Promise<void> {
478
+ await fs.mkdir(path.dirname(NOVELTY_CACHE_FILE), { recursive: true });
479
+ await fs.writeFile(NOVELTY_CACHE_FILE, JSON.stringify(entries, null, 2) + "\n", "utf-8");
480
+ }
481
+
482
+ function inspectNovelty(content: string, entries: NoveltyEntry[], threshold: number): {
483
+ allow: boolean;
484
+ contentHash: string | null;
485
+ tokenHashes: string[];
486
+ reason?: string;
487
+ } {
488
+ const normalized = normalizeMemoryText(content);
489
+ if (normalized.length < MIN_NOVELTY_CHARS) {
490
+ return { allow: true, contentHash: null, tokenHashes: [] };
491
+ }
492
+
493
+ const contentHash = createHash("sha256").update(normalized).digest("hex").slice(0, 24);
494
+ if (entries.some((entry) => entry.hash === contentHash)) {
495
+ return { allow: false, contentHash, tokenHashes: [], reason: "exact duplicate" };
496
+ }
497
+
498
+ const tokenHashes = buildTokenHashes(normalized);
499
+ const currentSet = new Set(tokenHashes);
500
+
501
+ for (const entry of entries) {
502
+ const score = jaccardSimilarity(currentSet, new Set(entry.tokenHashes || []));
503
+ if (score >= threshold) {
504
+ return {
505
+ allow: false,
506
+ contentHash,
507
+ tokenHashes,
508
+ reason: `near duplicate (similarity ${score.toFixed(2)})`,
509
+ };
510
+ }
511
+ }
512
+
513
+ return { allow: true, contentHash, tokenHashes };
514
+ }
515
+
516
+ async function createNoveltyGate(config: SCConfig): Promise<{
517
+ inspect: (content: string) => { allow: boolean; contentHash: string | null; tokenHashes: string[]; reason?: string };
518
+ remember: (memory: { title: string; category: string }, novelty: { contentHash: string | null; tokenHashes: string[] }) => void;
519
+ flush: () => Promise<void>;
520
+ }> {
521
+ const thresholdRaw = Number(config.openclawAutoMemoryNoveltyThreshold);
522
+ const maxRecentRaw = Number(config.openclawAutoMemoryMaxRecent);
523
+ const threshold = Number.isFinite(thresholdRaw)
524
+ ? clamp(thresholdRaw, 0.6, 0.99)
525
+ : DEFAULT_NOVELTY_THRESHOLD;
526
+ const maxRecent = Number.isFinite(maxRecentRaw)
527
+ ? Math.floor(clamp(maxRecentRaw, 50, 1000))
528
+ : DEFAULT_MAX_RECENT;
529
+
530
+ const enabled = isAutoMemoryDedupeEnabled(config);
531
+ const entries = enabled ? await loadNoveltyCache(maxRecent) : [];
532
+ let dirty = false;
533
+
534
+ return {
535
+ inspect(content: string) {
536
+ if (!enabled) return { allow: true, contentHash: null, tokenHashes: [] };
537
+ return inspectNovelty(content, entries, threshold);
538
+ },
539
+ remember(memory, novelty) {
540
+ if (!enabled || !novelty.contentHash || novelty.tokenHashes.length === 0) return;
541
+ entries.unshift({
542
+ hash: novelty.contentHash,
543
+ tokenHashes: novelty.tokenHashes,
544
+ title: String(memory.title || "").slice(0, 120),
545
+ category: String(memory.category || "note"),
546
+ createdAt: new Date().toISOString(),
547
+ });
548
+ if (entries.length > maxRecent) entries.length = maxRecent;
549
+ dirty = true;
550
+ },
551
+ async flush() {
552
+ if (!enabled || !dirty) return;
553
+ await saveNoveltyCache(entries);
554
+ },
555
+ };
556
+ }
557
+
558
+ // ==================== HOOK HANDLERS ====================
559
+
560
+ // Skip scanning internal OpenClaw content (boot checks, system prompts, heartbeats)
561
+ const SKIP_PATTERNS = [
562
+ /^You are running a boot check/i,
563
+ /^Read HEARTBEAT\.md/i,
564
+ /^System:/,
565
+ /^\[System Message\]/,
566
+ /^HEARTBEAT_OK$/,
567
+ /^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s/, // Timestamped system events
568
+ /^A subagent task/i,
569
+ /subagent.*completed/i,
570
+ ];
571
+ function isInternalContent(text: string): boolean {
572
+ return SKIP_PATTERNS.some(p => p.test(text.trim()));
573
+ }
574
+
575
+ function handleLlmInput(event: LlmInputEvent, ctx: AgentCtx): void {
576
+ // Fire and forget
577
+ (async () => {
578
+ try {
579
+ // Only scan user content, skip system/boot/heartbeat prompts
580
+ const userTexts = extractUserContent(event.historyMessages).slice(-5);
581
+ const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t));
582
+ for (const text of texts) {
583
+ if (!text || text.length < 10) continue;
584
+ const result = await scanRealtimeContent(text);
585
+ if (!result.clean) {
586
+ console.warn(`[shieldcortex] ⚠️ Threat in LLM input: ${result.summary}`);
587
+ const entry = {
588
+ type: "threat", hook: "llm_input", sessionId: event.sessionId,
589
+ model: event.model, reason: result.summary,
590
+ preview: text.slice(0, 100), ts: new Date().toISOString(),
591
+ };
592
+ auditLog(entry);
593
+ cloudSync({ ...entry, content: text.slice(0, 200) });
594
+ }
595
+ }
596
+ } catch (e) {
597
+ console.error("[shieldcortex] llm_input error:", e instanceof Error ? e.message : String(e));
598
+ }
599
+ })();
600
+ }
601
+
602
+ // Skip text blocks that are ShieldCortex/OpenClaw tool-result pass-throughs
603
+ function isToolResultContent(text: string): boolean {
604
+ // ShieldCortex recall returns "Found N memories:" header
605
+ if (/^Found \d+ memor(?:y|ies):/m.test(text)) return true;
606
+ // ShieldCortex get_context returns structured context blocks
607
+ if (/^## (?:Architecture|Patterns|Preferences|Errors|Context)/m.test(text)) return true;
608
+ // OpenClaw tool-result wrapper markers
609
+ if (/^\[tool_result\b/i.test(text.trim())) return true;
610
+ if (/^<tool_result\b/i.test(text.trim())) return true;
611
+ return false;
612
+ }
613
+
614
+ function handleLlmOutput(event: LlmOutputEvent, ctx: AgentCtx): void {
615
+ // Fire and forget
616
+ (async () => {
617
+ try {
618
+ const config = await loadConfig();
619
+ if (!isAutoMemoryEnabled(config)) return;
620
+
621
+ const texts = event.assistantTexts
622
+ .filter(t => t && t.length >= 30)
623
+ .filter(t => !isToolResultContent(t));
624
+ if (!texts.length) return;
625
+ const memories = extractMemories(texts);
626
+ if (!memories.length) return;
627
+
628
+ const noveltyGate = await createNoveltyGate(config);
629
+ let saved = 0;
630
+ let skipped = 0;
631
+ for (const mem of memories) {
632
+ const novelty = noveltyGate.inspect(mem.content);
633
+ if (!novelty.allow) {
634
+ skipped++;
635
+ continue;
636
+ }
637
+
638
+ const r = await callCortex("remember", {
639
+ title: mem.title, content: mem.content, category: mem.category,
640
+ project: ctx.agentId || "openclaw", scope: "global",
641
+ importance: "normal", tags: "auto-extracted,realtime-plugin,llm-output",
642
+ sourceType: "agent", sourceIdentifier: `openclaw-plugin:${event.sessionId}`,
643
+ sessionId: event.sessionId, agentId: ctx.agentId || "openclaw", workspaceDir: ctx.workspaceDir || "",
644
+ });
645
+ if (r) {
646
+ saved++;
647
+ noveltyGate.remember(mem, novelty);
648
+ }
649
+ }
650
+ await noveltyGate.flush();
651
+ if (saved) {
652
+ console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output (${skipped} duplicates skipped)`);
653
+ auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, skipped, ts: new Date().toISOString() });
654
+ }
655
+ } catch (e) {
656
+ console.error("[shieldcortex] llm_output error:", e instanceof Error ? e.message : String(e));
657
+ }
658
+ })();
659
+ }
660
+
661
+ // ==================== PLUGIN EXPORT ====================
662
+
663
+ export default {
664
+ id: PLUGIN_ID,
665
+ name: "ShieldCortex Real-time Scanner",
666
+ description: "Real-time defence scanning on LLM inputs with optional memory extraction from outputs",
667
+ version: _version,
668
+ configSchema: {
669
+ parse(value: unknown) {
670
+ return normaliseConfig(value);
671
+ },
672
+ uiHints: PLUGIN_CONFIG_UI_HINTS,
673
+ jsonSchema: PLUGIN_CONFIG_JSON_SCHEMA,
674
+ },
675
+
676
+ register(api: PluginApi) {
677
+ applyPluginConfigOverride(api);
678
+ api.on("llm_input", handleLlmInput);
679
+ api.on("llm_output", handleLlmOutput);
680
+ api.logger.info("[shieldcortex] Real-time scanning plugin registered (llm_input + llm_output)");
681
+ },
682
+ };
@@ -0,0 +1,79 @@
1
+ {
2
+ "id": "shieldcortex-realtime",
3
+ "version": "3.4.4",
4
+ "name": "ShieldCortex Real-time Scanner",
5
+ "description": "Real-time defence scanning on LLM input and memory extraction on LLM output.",
6
+ "uiHints": {
7
+ "binaryPath": {
8
+ "label": "ShieldCortex Binary Path",
9
+ "help": "Optional absolute path to the shieldcortex CLI when it is not on PATH.",
10
+ "placeholder": "/usr/local/bin/shieldcortex",
11
+ "advanced": true
12
+ },
13
+ "cloudApiKey": {
14
+ "label": "Cloud API Key",
15
+ "help": "Optional ShieldCortex Cloud API key used for realtime threat forwarding.",
16
+ "placeholder": "sc_...",
17
+ "sensitive": true
18
+ },
19
+ "cloudBaseUrl": {
20
+ "label": "Cloud Base URL",
21
+ "help": "Override the ShieldCortex Cloud API base URL if you use a self-hosted or staging endpoint.",
22
+ "placeholder": "https://api.shieldcortex.ai",
23
+ "advanced": true
24
+ },
25
+ "openclawAutoMemory": {
26
+ "label": "Auto Memory Extraction",
27
+ "help": "Extract high-signal decisions and learnings from LLM output into ShieldCortex memory."
28
+ },
29
+ "openclawAutoMemoryDedupe": {
30
+ "label": "Dedupe Auto Memory",
31
+ "help": "Skip near-duplicate memories before they are written to ShieldCortex.",
32
+ "advanced": true
33
+ },
34
+ "openclawAutoMemoryNoveltyThreshold": {
35
+ "label": "Novelty Threshold",
36
+ "help": "Similarity threshold for duplicate suppression. Higher values keep more memories.",
37
+ "advanced": true
38
+ },
39
+ "openclawAutoMemoryMaxRecent": {
40
+ "label": "Recent Memory Cache Size",
41
+ "help": "How many recent extracted memories to keep in the dedupe cache.",
42
+ "advanced": true
43
+ }
44
+ },
45
+ "configSchema": {
46
+ "type": "object",
47
+ "additionalProperties": false,
48
+ "properties": {
49
+ "enabled": {
50
+ "type": "boolean"
51
+ },
52
+ "binaryPath": {
53
+ "type": "string"
54
+ },
55
+ "cloudApiKey": {
56
+ "type": "string"
57
+ },
58
+ "cloudBaseUrl": {
59
+ "type": "string"
60
+ },
61
+ "openclawAutoMemory": {
62
+ "type": "boolean"
63
+ },
64
+ "openclawAutoMemoryDedupe": {
65
+ "type": "boolean"
66
+ },
67
+ "openclawAutoMemoryNoveltyThreshold": {
68
+ "type": "number",
69
+ "minimum": 0.6,
70
+ "maximum": 0.99
71
+ },
72
+ "openclawAutoMemoryMaxRecent": {
73
+ "type": "integer",
74
+ "minimum": 50,
75
+ "maximum": 1000
76
+ }
77
+ }
78
+ }
79
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@drakon-systems/shieldcortex-realtime",
3
+ "version": "3.4.4",
4
+ "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "openclaw",
10
+ "openclaw-plugin",
11
+ "shieldcortex",
12
+ "security",
13
+ "prompt-injection",
14
+ "memory"
15
+ ],
16
+ "files": [
17
+ "index.ts",
18
+ "openclaw.plugin.json",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "pack:verify": "npm pack --dry-run"
23
+ },
24
+ "peerDependencies": {
25
+ "shieldcortex": ">=3.4.4"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "openclaw": {
31
+ "extensions": [
32
+ "./index.ts"
33
+ ]
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/Drakon-Systems-Ltd/ShieldCortex.git",
38
+ "directory": "plugins/openclaw"
39
+ },
40
+ "homepage": "https://github.com/Drakon-Systems-Ltd/ShieldCortex/tree/main/plugins/openclaw#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/Drakon-Systems-Ltd/ShieldCortex/issues"
43
+ }
44
+ }