@drakon-systems/shieldcortex-realtime 4.31.2 → 4.32.1

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/dist/index.js CHANGED
@@ -77,7 +77,11 @@ function collectRuntimeCandidates() {
77
77
  }
78
78
  return [...candidates];
79
79
  }
80
+ // Test seam: lets the jest suite inject a spy runtime without touching disk.
81
+ let _runtimeOverride = null;
80
82
  async function getRuntime() {
83
+ if (_runtimeOverride)
84
+ return _runtimeOverride;
81
85
  if (!runtimePromise) {
82
86
  runtimePromise = (async () => {
83
87
  const tried = [];
@@ -100,6 +104,43 @@ async function getRuntime() {
100
104
  }
101
105
  return runtimePromise;
102
106
  }
107
+ // ==================== SHARED IN-PROCESS DEFENCE MODULE ====================
108
+ // `shieldcortex/defence` is loaded ONCE and shared by both the
109
+ // `before_tool_call` interceptor (runDefencePipeline) and realtime scanning
110
+ // (scanToolResponse). The dynamic import uses a string-concatenated specifier
111
+ // so TypeScript does not resolve it at compile time — the module only exists at
112
+ // runtime when the package is installed, not during CI builds of the plugin.
113
+ // Returns null (cached) when the module is unavailable so callers can fall back
114
+ // to the mcporter shell-out gracefully.
115
+ let _defenceModPromise = null;
116
+ let _defenceModOverride; // undefined = not overridden
117
+ async function getDefenceModule() {
118
+ if (_defenceModOverride !== undefined)
119
+ return _defenceModOverride;
120
+ if (!_defenceModPromise) {
121
+ _defenceModPromise = (async () => {
122
+ try {
123
+ const defenceModPath = 'shieldcortex' + '/defence';
124
+ return (await import(/* webpackIgnore: true */ defenceModPath));
125
+ }
126
+ catch {
127
+ // Older install / package not resolvable — caller falls back.
128
+ return null;
129
+ }
130
+ })();
131
+ }
132
+ return _defenceModPromise;
133
+ }
134
+ // Test seams (jest only): inject a stub defence module / spy runtime, then reset.
135
+ export function __setDefenceModuleForTest(mod) {
136
+ _defenceModOverride = mod;
137
+ _defenceModPromise = null;
138
+ }
139
+ export function __setRuntimeForTest(runtime) {
140
+ _runtimeOverride = runtime;
141
+ if (runtime)
142
+ runtimePromise = null;
143
+ }
103
144
  const PLUGIN_ID = "shieldcortex-realtime";
104
145
  const PLUGIN_PACKAGE_NAME = "@drakon-systems/shieldcortex-realtime";
105
146
  const PLUGIN_CONFIG_UI_HINTS = {
@@ -262,15 +303,10 @@ async function callCortex(tool, args = {}) {
262
303
  return (await getRuntime()).callCortex(tool, args);
263
304
  }
264
305
  // ==================== REMOTE SCANNING ====================
265
- async function scanRealtimeContent(text) {
266
- const response = await callCortex("scan_tool_response", {
267
- toolName: "openclaw-realtime",
268
- content: text,
269
- mode: "advisory",
270
- });
271
- if (!response) {
272
- return { clean: true, summary: "scan unavailable" };
273
- }
306
+ // Build the `{ clean, summary }` contract from the parsed MCP text response.
307
+ // Kept identical to the historical regex parse so the fallback degrades to the
308
+ // exact behaviour callers depended on before in-process scanning landed.
309
+ function parseScanResponse(response) {
274
310
  const cleanMatch = response.match(/\*\*Clean:\*\*\s*(Yes|No)/i);
275
311
  const riskMatch = response.match(/\*\*Risk Level:\*\*\s*([A-Za-z]+)/i);
276
312
  const detectionsMatch = response.match(/\*\*Detections:\*\*\s*(\d+)/i);
@@ -280,6 +316,34 @@ async function scanRealtimeContent(text) {
280
316
  const summary = detections ? `${risk} (${detections} detections)` : risk;
281
317
  return { clean, summary };
282
318
  }
319
+ export async function scanRealtimeContent(text) {
320
+ // PRIMARY: scan in-process via the shared shieldcortex/defence module. The
321
+ // scan is pure (no DB handle required — scanToolResponse's audit write is
322
+ // guarded by isDatabaseInitialized()), so it is safe in the long-lived
323
+ // gateway and avoids booting a cold MCP server per message.
324
+ const defenceMod = await getDefenceModule();
325
+ if (defenceMod && typeof defenceMod.scanToolResponse === "function") {
326
+ const scan = defenceMod.scanToolResponse("openclaw-realtime", text, "advisory");
327
+ // Reproduce the historical summary contract exactly: risk level + detection
328
+ // count only when the injection scan flagged something.
329
+ const risk = scan.injection.clean ? "unknown" : scan.injection.riskLevel;
330
+ const summary = scan.injection.clean
331
+ ? risk
332
+ : `${risk} (${scan.injection.detections.length} detections)`;
333
+ return { clean: scan.clean, summary };
334
+ }
335
+ // FALLBACK: in-process defence unavailable (older install, import failed) —
336
+ // degrade to the MCP shell-out so scanning still happens rather than breaking.
337
+ const response = await callCortex("scan_tool_response", {
338
+ toolName: "openclaw-realtime",
339
+ content: text,
340
+ mode: "advisory",
341
+ });
342
+ if (!response) {
343
+ return { clean: true, summary: "scan unavailable" };
344
+ }
345
+ return parseScanResponse(response);
346
+ }
283
347
  // ==================== CONTENT PATTERNS ====================
284
348
  const PATTERNS = {
285
349
  architecture: [/\b(?:architecture|designed|structured)\b.*?(?:uses?|is|with)\b/i, /\b(?:decided?\s+to|going\s+with|chose)\b/i],
@@ -477,37 +541,41 @@ const SKIP_PATTERNS = [
477
541
  function isInternalContent(text) {
478
542
  return SKIP_PATTERNS.some(p => p.test(text.trim()));
479
543
  }
480
- function handleLlmInput(event, ctx) {
481
- // Fire and forget
482
- (async () => {
483
- try {
484
- // Only scan user content, skip system/boot/heartbeat prompts
485
- const userTexts = extractUserContent(event.historyMessages).slice(-5);
486
- const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t));
487
- for (const text of texts) {
488
- if (!text || text.length < 10)
489
- continue;
490
- const result = await scanRealtimeContent(text);
491
- if (!result.clean) {
492
- console.warn(`[shieldcortex] ⚠️ Threat in LLM input: ${result.summary}`);
493
- const entry = {
494
- type: "threat", hook: "llm_input", sessionId: event.sessionId,
495
- model: event.model, reason: result.summary,
496
- preview: text.slice(0, 100), ts: new Date().toISOString(),
497
- };
498
- auditLog(entry);
499
- loadConfig()
500
- // Pass the local entry as-is; cloudSync strips the input preview/content
501
- // before transmit (metadata-only egress). No raw LLM input leaves here.
502
- .then(cfg => cloudSync(entry, cfg))
503
- .catch(() => { });
504
- }
544
+ // Awaitable scan body — extracted so the jest suite can verify behaviour
545
+ // deterministically. handleLlmInput wraps this fire-and-forget so the hook
546
+ // itself stays non-blocking.
547
+ export async function scanLlmInput(event, _ctx) {
548
+ try {
549
+ // Only scan user content, skip system/boot/heartbeat prompts
550
+ const userTexts = extractUserContent(event.historyMessages).slice(-5);
551
+ const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t));
552
+ for (const text of texts) {
553
+ if (!text || text.length < 10)
554
+ continue;
555
+ const result = await scanRealtimeContent(text);
556
+ if (!result.clean) {
557
+ console.warn(`[shieldcortex] ⚠️ Threat in LLM input: ${result.summary}`);
558
+ const entry = {
559
+ type: "threat", hook: "llm_input", sessionId: event.sessionId,
560
+ model: event.model, reason: result.summary,
561
+ preview: text.slice(0, 100), ts: new Date().toISOString(),
562
+ };
563
+ auditLog(entry);
564
+ loadConfig()
565
+ // Pass the local entry as-is; cloudSync strips the input preview/content
566
+ // before transmit (metadata-only egress). No raw LLM input leaves here.
567
+ .then(cfg => cloudSync(entry, cfg))
568
+ .catch(() => { });
505
569
  }
506
570
  }
507
- catch (e) {
508
- console.error("[shieldcortex] llm_input error:", e instanceof Error ? e.message : String(e));
509
- }
510
- })();
571
+ }
572
+ catch (e) {
573
+ console.error("[shieldcortex] llm_input error:", e instanceof Error ? e.message : String(e));
574
+ }
575
+ }
576
+ function handleLlmInput(event, ctx) {
577
+ // Fire and forget
578
+ void scanLlmInput(event, ctx);
511
579
  }
512
580
  // Skip text blocks that are ShieldCortex/OpenClaw tool-result pass-throughs
513
581
  function isToolResultContent(text) {
@@ -611,17 +679,13 @@ export default {
611
679
  };
612
680
  if (!interceptorConfig.enabled)
613
681
  return null;
614
- // Dynamic import with string variable to prevent TypeScript from resolving
615
- // at compile time 'shieldcortex/defence' only exists at runtime when the
616
- // package is installed globally, not during CI builds of the plugin itself.
617
- let defenceMod;
618
- try {
619
- const defenceModPath = 'shieldcortex' + '/defence';
620
- defenceMod = await import(/* webpackIgnore: true */ defenceModPath);
621
- }
622
- catch (importErr) {
623
- // Stack overflow or missing module — interceptor can't load
624
- api.logger?.warn?.(`[shieldcortex] Cannot load defence module: ${importErr instanceof Error ? importErr.message : importErr}`);
682
+ // Shared in-process defence module (same instance realtime scanning
683
+ // usessee getDefenceModule). Loaded via a string-concatenated
684
+ // specifier so TypeScript doesn't resolve 'shieldcortex/defence' at
685
+ // compile time; it only exists at runtime once the package is installed.
686
+ const defenceMod = await getDefenceModule();
687
+ if (!defenceMod) {
688
+ api.logger?.warn?.('[shieldcortex] Cannot load defence module interceptor disabled');
625
689
  return null;
626
690
  }
627
691
  if (typeof defenceMod.runDefencePipeline !== 'function')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.31.2",
3
+ "version": "4.32.1",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
package/index.ts CHANGED
@@ -28,6 +28,21 @@ type OpenClawRuntime = {
28
28
  loadShieldConfig: () => Promise<any>;
29
29
  };
30
30
 
31
+ // The subset of `shieldcortex/defence` the plugin uses in-process. Both the
32
+ // `before_tool_call` interceptor (runDefencePipeline) and realtime scanning
33
+ // (scanToolResponse) load from the SAME module via getDefenceModule().
34
+ type DefenceModule = {
35
+ runDefencePipeline?: (...args: any[]) => any;
36
+ scanToolResponse?: (
37
+ toolName: string,
38
+ content: string,
39
+ mode?: 'advisory' | 'enforce',
40
+ ) => {
41
+ clean: boolean;
42
+ injection: { clean: boolean; riskLevel: string; detections: unknown[] };
43
+ };
44
+ };
45
+
31
46
  let runtimePromise: Promise<OpenClawRuntime> | null = null;
32
47
 
33
48
  function addRuntimeCandidate(candidates: Set<string>, packageRoot: string) {
@@ -95,7 +110,11 @@ function collectRuntimeCandidates(): string[] {
95
110
  return [...candidates];
96
111
  }
97
112
 
113
+ // Test seam: lets the jest suite inject a spy runtime without touching disk.
114
+ let _runtimeOverride: OpenClawRuntime | null = null;
115
+
98
116
  async function getRuntime(): Promise<OpenClawRuntime> {
117
+ if (_runtimeOverride) return _runtimeOverride;
99
118
  if (!runtimePromise) {
100
119
  runtimePromise = (async () => {
101
120
  const tried: string[] = [];
@@ -121,6 +140,43 @@ async function getRuntime(): Promise<OpenClawRuntime> {
121
140
  return runtimePromise;
122
141
  }
123
142
 
143
+ // ==================== SHARED IN-PROCESS DEFENCE MODULE ====================
144
+ // `shieldcortex/defence` is loaded ONCE and shared by both the
145
+ // `before_tool_call` interceptor (runDefencePipeline) and realtime scanning
146
+ // (scanToolResponse). The dynamic import uses a string-concatenated specifier
147
+ // so TypeScript does not resolve it at compile time — the module only exists at
148
+ // runtime when the package is installed, not during CI builds of the plugin.
149
+ // Returns null (cached) when the module is unavailable so callers can fall back
150
+ // to the mcporter shell-out gracefully.
151
+ let _defenceModPromise: Promise<DefenceModule | null> | null = null;
152
+ let _defenceModOverride: DefenceModule | null | undefined; // undefined = not overridden
153
+
154
+ async function getDefenceModule(): Promise<DefenceModule | null> {
155
+ if (_defenceModOverride !== undefined) return _defenceModOverride;
156
+ if (!_defenceModPromise) {
157
+ _defenceModPromise = (async () => {
158
+ try {
159
+ const defenceModPath = 'shieldcortex' + '/defence';
160
+ return (await import(/* webpackIgnore: true */ defenceModPath)) as DefenceModule;
161
+ } catch {
162
+ // Older install / package not resolvable — caller falls back.
163
+ return null;
164
+ }
165
+ })();
166
+ }
167
+ return _defenceModPromise;
168
+ }
169
+
170
+ // Test seams (jest only): inject a stub defence module / spy runtime, then reset.
171
+ export function __setDefenceModuleForTest(mod: DefenceModule | null | undefined): void {
172
+ _defenceModOverride = mod;
173
+ _defenceModPromise = null;
174
+ }
175
+ export function __setRuntimeForTest(runtime: OpenClawRuntime | null): void {
176
+ _runtimeOverride = runtime;
177
+ if (runtime) runtimePromise = null;
178
+ }
179
+
124
180
  type LlmInputEvent = {
125
181
  runId: string; sessionId: string; provider: string; model: string;
126
182
  systemPrompt?: string; prompt: string; historyMessages: unknown[]; imagesCount: number;
@@ -331,17 +387,10 @@ async function callCortex(tool: string, args: Record<string, string> = {}): Prom
331
387
 
332
388
  // ==================== REMOTE SCANNING ====================
333
389
 
334
- async function scanRealtimeContent(text: string): Promise<{ clean: boolean; summary: string }> {
335
- const response = await callCortex("scan_tool_response", {
336
- toolName: "openclaw-realtime",
337
- content: text,
338
- mode: "advisory",
339
- });
340
-
341
- if (!response) {
342
- return { clean: true, summary: "scan unavailable" };
343
- }
344
-
390
+ // Build the `{ clean, summary }` contract from the parsed MCP text response.
391
+ // Kept identical to the historical regex parse so the fallback degrades to the
392
+ // exact behaviour callers depended on before in-process scanning landed.
393
+ function parseScanResponse(response: string): { clean: boolean; summary: string } {
345
394
  const cleanMatch = response.match(/\*\*Clean:\*\*\s*(Yes|No)/i);
346
395
  const riskMatch = response.match(/\*\*Risk Level:\*\*\s*([A-Za-z]+)/i);
347
396
  const detectionsMatch = response.match(/\*\*Detections:\*\*\s*(\d+)/i);
@@ -354,6 +403,38 @@ async function scanRealtimeContent(text: string): Promise<{ clean: boolean; summ
354
403
  return { clean, summary };
355
404
  }
356
405
 
406
+ export async function scanRealtimeContent(text: string): Promise<{ clean: boolean; summary: string }> {
407
+ // PRIMARY: scan in-process via the shared shieldcortex/defence module. The
408
+ // scan is pure (no DB handle required — scanToolResponse's audit write is
409
+ // guarded by isDatabaseInitialized()), so it is safe in the long-lived
410
+ // gateway and avoids booting a cold MCP server per message.
411
+ const defenceMod = await getDefenceModule();
412
+ if (defenceMod && typeof defenceMod.scanToolResponse === "function") {
413
+ const scan = defenceMod.scanToolResponse("openclaw-realtime", text, "advisory");
414
+ // Reproduce the historical summary contract exactly: risk level + detection
415
+ // count only when the injection scan flagged something.
416
+ const risk = scan.injection.clean ? "unknown" : scan.injection.riskLevel;
417
+ const summary = scan.injection.clean
418
+ ? risk
419
+ : `${risk} (${scan.injection.detections.length} detections)`;
420
+ return { clean: scan.clean, summary };
421
+ }
422
+
423
+ // FALLBACK: in-process defence unavailable (older install, import failed) —
424
+ // degrade to the MCP shell-out so scanning still happens rather than breaking.
425
+ const response = await callCortex("scan_tool_response", {
426
+ toolName: "openclaw-realtime",
427
+ content: text,
428
+ mode: "advisory",
429
+ });
430
+
431
+ if (!response) {
432
+ return { clean: true, summary: "scan unavailable" };
433
+ }
434
+
435
+ return parseScanResponse(response);
436
+ }
437
+
357
438
  // ==================== CONTENT PATTERNS ====================
358
439
 
359
440
  const PATTERNS: Record<string, RegExp[]> = {
@@ -579,35 +660,40 @@ function isInternalContent(text: string): boolean {
579
660
  return SKIP_PATTERNS.some(p => p.test(text.trim()));
580
661
  }
581
662
 
582
- function handleLlmInput(event: LlmInputEvent, ctx: AgentCtx): void {
583
- // Fire and forget
584
- (async () => {
585
- try {
586
- // Only scan user content, skip system/boot/heartbeat prompts
587
- const userTexts = extractUserContent(event.historyMessages).slice(-5);
588
- const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t));
589
- for (const text of texts) {
590
- if (!text || text.length < 10) continue;
591
- const result = await scanRealtimeContent(text);
592
- if (!result.clean) {
593
- console.warn(`[shieldcortex] ⚠️ Threat in LLM input: ${result.summary}`);
594
- const entry = {
595
- type: "threat", hook: "llm_input", sessionId: event.sessionId,
596
- model: event.model, reason: result.summary,
597
- preview: text.slice(0, 100), ts: new Date().toISOString(),
598
- };
599
- auditLog(entry);
600
- loadConfig()
601
- // Pass the local entry as-is; cloudSync strips the input preview/content
602
- // before transmit (metadata-only egress). No raw LLM input leaves here.
603
- .then(cfg => cloudSync(entry, cfg))
604
- .catch(() => {});
605
- }
663
+ // Awaitable scan body extracted so the jest suite can verify behaviour
664
+ // deterministically. handleLlmInput wraps this fire-and-forget so the hook
665
+ // itself stays non-blocking.
666
+ export async function scanLlmInput(event: LlmInputEvent, _ctx: AgentCtx): Promise<void> {
667
+ try {
668
+ // Only scan user content, skip system/boot/heartbeat prompts
669
+ const userTexts = extractUserContent(event.historyMessages).slice(-5);
670
+ const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t));
671
+ for (const text of texts) {
672
+ if (!text || text.length < 10) continue;
673
+ const result = await scanRealtimeContent(text);
674
+ if (!result.clean) {
675
+ console.warn(`[shieldcortex] ⚠️ Threat in LLM input: ${result.summary}`);
676
+ const entry = {
677
+ type: "threat", hook: "llm_input", sessionId: event.sessionId,
678
+ model: event.model, reason: result.summary,
679
+ preview: text.slice(0, 100), ts: new Date().toISOString(),
680
+ };
681
+ auditLog(entry);
682
+ loadConfig()
683
+ // Pass the local entry as-is; cloudSync strips the input preview/content
684
+ // before transmit (metadata-only egress). No raw LLM input leaves here.
685
+ .then(cfg => cloudSync(entry, cfg))
686
+ .catch(() => {});
606
687
  }
607
- } catch (e) {
608
- console.error("[shieldcortex] llm_input error:", e instanceof Error ? e.message : String(e));
609
688
  }
610
- })();
689
+ } catch (e) {
690
+ console.error("[shieldcortex] llm_input error:", e instanceof Error ? e.message : String(e));
691
+ }
692
+ }
693
+
694
+ function handleLlmInput(event: LlmInputEvent, ctx: AgentCtx): void {
695
+ // Fire and forget
696
+ void scanLlmInput(event, ctx);
611
697
  }
612
698
 
613
699
  // Skip text blocks that are ShieldCortex/OpenClaw tool-result pass-throughs
@@ -713,16 +799,13 @@ export default {
713
799
 
714
800
  if (!interceptorConfig.enabled) return null;
715
801
 
716
- // Dynamic import with string variable to prevent TypeScript from resolving
717
- // at compile time 'shieldcortex/defence' only exists at runtime when the
718
- // package is installed globally, not during CI builds of the plugin itself.
719
- let defenceMod: any;
720
- try {
721
- const defenceModPath = 'shieldcortex' + '/defence';
722
- defenceMod = await import(/* webpackIgnore: true */ defenceModPath);
723
- } catch (importErr) {
724
- // Stack overflow or missing module — interceptor can't load
725
- (api.logger as any)?.warn?.(`[shieldcortex] Cannot load defence module: ${importErr instanceof Error ? importErr.message : importErr}`);
802
+ // Shared in-process defence module (same instance realtime scanning
803
+ // usessee getDefenceModule). Loaded via a string-concatenated
804
+ // specifier so TypeScript doesn't resolve 'shieldcortex/defence' at
805
+ // compile time; it only exists at runtime once the package is installed.
806
+ const defenceMod = await getDefenceModule();
807
+ if (!defenceMod) {
808
+ (api.logger as any)?.warn?.('[shieldcortex] Cannot load defence module — interceptor disabled');
726
809
  return null;
727
810
  }
728
811
  if (typeof defenceMod.runDefencePipeline !== 'function') return null;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.31.2",
3
+ "version": "4.32.1",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.31.2",
3
+ "version": "4.32.1",
4
4
  "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",