@drakon-systems/shieldcortex-realtime 3.4.4 → 4.8.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 CHANGED
@@ -32,7 +32,7 @@ openclaw plugins install @drakon-systems/shieldcortex-realtime
32
32
  If you also want the companion session hook, install it from the main package:
33
33
 
34
34
  ```bash
35
- openclaw hooks install shieldcortex
35
+ openclaw skills install shieldcortex
36
36
  ```
37
37
 
38
38
  Restart OpenClaw after installing:
package/index.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * ShieldCortex Real-time Scanning Plugin for OpenClaw v2026.2.15+
2
+ * ShieldCortex Real-time Scanning Plugin for OpenClaw v2026.3.22+
3
3
  *
4
- * Hooks into llm_input/llm_output for real-time defence scanning
5
- * and optional memory extraction. All operations are fire-and-forget.
4
+ * Uses explicit capability registration (registerHook + registerCommand)
5
+ * for llm_input/llm_output scanning and optional memory extraction.
6
+ * All scanning operations are fire-and-forget.
6
7
  */
7
8
 
8
- import { execFileSync } from "node:child_process";
9
9
  import { createHash } from "node:crypto";
10
10
  import fs from "node:fs/promises";
11
11
  import { existsSync, readFileSync, realpathSync } from "node:fs";
@@ -13,6 +13,10 @@ import path from "node:path";
13
13
  import { homedir } from "node:os";
14
14
  import { fileURLToPath, pathToFileURL } from "node:url";
15
15
 
16
+ import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
17
+ import type { InterceptorConfig, ToolCallContext } from './interceptor.js';
18
+ import { syncInterceptEvent } from './intercept-ingest.js';
19
+
16
20
  // ==================== RESILIENT RUNTIME LOADER ====================
17
21
  // Resolves runtime.mjs from multiple locations so the plugin works both
18
22
  // inside the npm package tree AND when copied to ~/.openclaw/extensions/
@@ -48,49 +52,43 @@ function collectRuntimeCandidates(): string[] {
48
52
  // 1. Relative path (works when running from within npm package tree)
49
53
  candidates.add(new URL("../../hooks/openclaw/cortex-memory/runtime.mjs", import.meta.url).href);
50
54
 
51
- // 2. Environment variable override
52
- if (process.env.SHIELDCORTEX_ROOT) {
53
- addRuntimeCandidate(candidates, process.env.SHIELDCORTEX_ROOT);
54
- }
55
+ // 2. Config file override (reads path from ~/.shieldcortex/config.json instead of env var)
56
+ try {
57
+ const cfgPath = path.join(homedir(), ".shieldcortex", "config.json");
58
+ if (existsSync(cfgPath)) {
59
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
60
+ if (cfg.installRoot) addRuntimeCandidate(candidates, cfg.installRoot);
61
+ }
62
+ } catch { /* no config */ }
55
63
 
56
64
  // 3. Walk up from current file location
57
65
  addAncestorCandidates(candidates, path.dirname(fileURLToPath(import.meta.url)));
58
66
 
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 {}
67
+ // 4. Resolve via common bin symlink paths (no child_process needed)
68
+ for (const binDir of ["/usr/local/bin", "/opt/homebrew/bin", path.join(homedir(), ".npm-global", "bin")]) {
69
+ const binPath = path.join(binDir, "shieldcortex");
70
+ try {
71
+ if (existsSync(binPath)) addAncestorCandidates(candidates, realpathSync(binPath));
72
+ } catch { /* broken symlink */ }
73
+ }
85
74
 
86
- // 7. Common global install paths
75
+ // 5. Common global install paths (covers npm root -g results without spawning npm)
87
76
  for (const root of [
88
77
  "/usr/lib/node_modules/shieldcortex",
89
78
  "/usr/local/lib/node_modules/shieldcortex",
90
79
  "/opt/homebrew/lib/node_modules/shieldcortex",
91
80
  path.join(homedir(), ".npm-global", "lib", "node_modules", "shieldcortex"),
81
+ path.join(homedir(), ".nvm", "versions", "node"), // nvm users
92
82
  ]) {
93
- addRuntimeCandidate(candidates, root);
83
+ if (root.includes(".nvm")) {
84
+ // For nvm, check the current symlink
85
+ try {
86
+ const currentNode = path.join(homedir(), ".nvm", "current", "lib", "node_modules", "shieldcortex");
87
+ addRuntimeCandidate(candidates, currentNode);
88
+ } catch { /* no nvm */ }
89
+ } else {
90
+ addRuntimeCandidate(candidates, root);
91
+ }
94
92
  }
95
93
 
96
94
  return [...candidates];
@@ -214,14 +212,16 @@ let _config: SCConfig | null = null;
214
212
  let _configOverride: SCConfig | null = null;
215
213
  let _version = "0.0.0";
216
214
  try {
217
- for (const packageUrl of [
215
+ // Try package.json first, then openclaw.plugin.json (the manifest IS copied to extensions/)
216
+ for (const candidateUrl of [
218
217
  new URL("./package.json", import.meta.url),
219
218
  new URL("../../package.json", import.meta.url),
219
+ new URL("./openclaw.plugin.json", import.meta.url),
220
220
  ]) {
221
221
  try {
222
- const pkg = JSON.parse(readFileSync(packageUrl, "utf-8"));
223
- if (typeof pkg.version === "string" && pkg.version.trim()) {
224
- _version = pkg.version;
222
+ const data = JSON.parse(readFileSync(candidateUrl, "utf-8"));
223
+ if (typeof data.version === "string" && data.version.trim()) {
224
+ _version = data.version;
225
225
  break;
226
226
  }
227
227
  } catch {
@@ -230,6 +230,8 @@ try {
230
230
  }
231
231
  } catch { /* fallback */ }
232
232
 
233
+ let _registered = false;
234
+
233
235
  function normaliseConfig(raw: unknown): SCConfig {
234
236
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
235
237
 
@@ -674,9 +676,125 @@ export default {
674
676
  },
675
677
 
676
678
  register(api: PluginApi) {
679
+ if (_registered) return;
680
+ _registered = true;
681
+ try {
677
682
  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)");
683
+
684
+ // --- Interceptor (lazy init) ---
685
+ let interceptorReady: ReturnType<typeof createInterceptor> | null = null;
686
+ let interceptorInitAttempted = false;
687
+
688
+ async function initInterceptor(): Promise<ReturnType<typeof createInterceptor> | null> {
689
+ if (interceptorInitAttempted) return interceptorReady;
690
+ interceptorInitAttempted = true;
691
+
692
+ try {
693
+ const scConfig = await loadConfig();
694
+ const rawInterceptorConfig = (scConfig as any).interceptor;
695
+ const interceptorConfig: InterceptorConfig = {
696
+ ...DEFAULT_INTERCEPTOR_CONFIG,
697
+ ...(rawInterceptorConfig && typeof rawInterceptorConfig === 'object' ? {
698
+ enabled: rawInterceptorConfig.enabled ?? DEFAULT_INTERCEPTOR_CONFIG.enabled,
699
+ severityActions: { ...DEFAULT_INTERCEPTOR_CONFIG.severityActions, ...rawInterceptorConfig.severityActions },
700
+ failurePolicy: { ...DEFAULT_INTERCEPTOR_CONFIG.failurePolicy, ...rawInterceptorConfig.failurePolicy },
701
+ } : {}),
702
+ logger: { info: api.logger?.info ?? console.log, warn: (api.logger as any)?.warn ?? console.warn },
703
+ };
704
+
705
+ if (!interceptorConfig.enabled) return null;
706
+
707
+ // Dynamic import with string variable to prevent TypeScript from resolving
708
+ // at compile time — 'shieldcortex/defence' only exists at runtime when the
709
+ // package is installed globally, not during CI builds of the plugin itself.
710
+ let defenceMod: any;
711
+ try {
712
+ const defenceModPath = 'shieldcortex' + '/defence';
713
+ defenceMod = await import(/* webpackIgnore: true */ defenceModPath);
714
+ } catch (importErr) {
715
+ // Stack overflow or missing module — interceptor can't load
716
+ (api.logger as any)?.warn?.(`[shieldcortex] Cannot load defence module: ${importErr instanceof Error ? importErr.message : importErr}`);
717
+ return null;
718
+ }
719
+ if (typeof defenceMod.runDefencePipeline !== 'function') return null;
720
+
721
+ interceptorReady = createInterceptor(interceptorConfig, defenceMod.runDefencePipeline as Parameters<typeof createInterceptor>[1], {
722
+ onAuditEntry: (entry) => syncInterceptEvent(entry, {
723
+ cloudApiKey: (scConfig as any).cloudApiKey ?? '',
724
+ cloudBaseUrl: (scConfig as any).cloudBaseUrl ?? 'https://api.shieldcortex.ai',
725
+ cloudEnabled: (scConfig as any).cloudEnabled ?? false,
726
+ }),
727
+ });
728
+ api.logger?.info?.('[shieldcortex] Interceptor active — watching: remember, mcp__memory__remember');
729
+ return interceptorReady;
730
+ } catch (err) {
731
+ (api.logger as any)?.warn?.(`[shieldcortex] Interceptor init failed: ${err instanceof Error ? err.message : err}`);
732
+ return null;
733
+ }
734
+ }
735
+
736
+ // Register before_tool_call with lazy-init wrapper
737
+ api.registerHook('before_tool_call', async (context: ToolCallContext) => {
738
+ const interceptor = await initInterceptor();
739
+ if (!interceptor) return;
740
+ try {
741
+ await interceptor.handleToolCall(context);
742
+ } catch (err) {
743
+ // Intentional blocks from the interceptor (ShieldCortex: ...) should propagate
744
+ if (err instanceof Error && err.message.startsWith('ShieldCortex:')) throw err;
745
+ // Unexpected errors (DB crash, etc.) — log and allow the tool call through
746
+ (api.logger as any)?.warn?.(`[shieldcortex] Interceptor error (allowing tool call): ${err instanceof Error ? err.message : err}`);
747
+ }
748
+ }, {
749
+ name: 'shieldcortex-intercept-tool',
750
+ description: 'Active threat gating on tool calls',
751
+ });
752
+
753
+ // Try to register session_end for cache cleanup
754
+ try {
755
+ api.registerHook('session_end', () => { interceptorReady?.resetSession(); }, {
756
+ name: 'shieldcortex-session-cleanup',
757
+ description: 'Clear interceptor deny cache on session end',
758
+ });
759
+ } catch {
760
+ // session_end may not be a supported hook — TTL safety net handles this
761
+ }
762
+
763
+ // Explicit capability registration (replaces legacy api.on)
764
+ api.registerHook("llm_input", handleLlmInput, {
765
+ name: "shieldcortex-scan-input",
766
+ description: "Real-time threat scanning on LLM input",
767
+ });
768
+ api.registerHook("llm_output", handleLlmOutput, {
769
+ name: "shieldcortex-scan-output",
770
+ description: "Memory extraction from LLM output",
771
+ });
772
+
773
+ // Register a lightweight status command so the plugin is not hook-only
774
+ api.registerCommand({
775
+ name: "shieldcortex-status",
776
+ description: "Show ShieldCortex real-time scanner status",
777
+ async handler() {
778
+ const cfg = await loadConfig();
779
+ const autoMemory = isAutoMemoryEnabled(cfg) ? "on" : "off";
780
+ const dedupe = isAutoMemoryDedupeEnabled(cfg) ? "on" : "off";
781
+ const cloud = cfg.cloudApiKey ? "configured" : "not configured";
782
+ return {
783
+ text:
784
+ `ShieldCortex v${_version}\n` +
785
+ ` Hooks: llm_input (scan), llm_output (memory)\n` +
786
+ ` Auto memory: ${autoMemory} | Dedupe: ${dedupe}\n` +
787
+ ` Cloud sync: ${cloud}`,
788
+ };
789
+ },
790
+ });
791
+
792
+ api.logger.info(`[shieldcortex] v${_version} registered (llm_input + llm_output + before_tool_call + /shieldcortex-status)`);
793
+ } catch (err) {
794
+ // Plugin must never block channel startup — warn and bail gracefully
795
+ const msg = err instanceof Error ? err.message : String(err);
796
+ console.warn(`[shieldcortex] WARNING: Plugin failed to initialize: ${msg}`);
797
+ console.warn('[shieldcortex] Real-time scanning is disabled. Channels will start normally.');
798
+ }
681
799
  },
682
800
  };
@@ -0,0 +1,28 @@
1
+ // plugins/openclaw/intercept-ingest.ts
2
+ import type { InterceptAuditEntry } from './interceptor.js';
3
+
4
+ interface CloudConfig {
5
+ cloudApiKey: string;
6
+ cloudBaseUrl: string;
7
+ cloudEnabled: boolean;
8
+ }
9
+
10
+ export function syncInterceptEvent(event: InterceptAuditEntry, config: CloudConfig): void {
11
+ if (!config.cloudEnabled || !config.cloudApiKey) return;
12
+
13
+ const url = `${config.cloudBaseUrl}/v1/audit/ingest`;
14
+
15
+ fetch(url, {
16
+ method: 'POST',
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ Authorization: `Bearer ${config.cloudApiKey}`,
20
+ },
21
+ body: JSON.stringify({
22
+ events: [{ ...event, source: 'openclaw-interceptor' }],
23
+ }),
24
+ signal: AbortSignal.timeout(5_000),
25
+ }).catch(() => {
26
+ // Fire-and-forget — never block on cloud sync failure
27
+ });
28
+ }
package/interceptor.ts ADDED
@@ -0,0 +1,461 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, appendFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+
6
+ export type Severity = 'low' | 'medium' | 'high' | 'critical';
7
+ export type InterceptAction = 'log' | 'warn' | 'require_approval';
8
+ export type FailureAction = 'allow' | 'deny';
9
+
10
+ export interface InterceptorConfig {
11
+ enabled: boolean;
12
+ severityActions: Record<Severity, InterceptAction>;
13
+ failurePolicy: Record<Severity, FailureAction>;
14
+ logger?: { info: (msg: string) => void; warn: (msg: string) => void };
15
+ }
16
+
17
+ export interface ToolCallContext {
18
+ toolName: string;
19
+ arguments: Record<string, unknown>;
20
+ requireApproval?: (message: string) => Promise<boolean>;
21
+ }
22
+
23
+ export interface InterceptAuditEntry {
24
+ type: 'intercept';
25
+ tool: string;
26
+ severity: Severity;
27
+ firewallResult: string;
28
+ threats: string[];
29
+ anomalyScore: number;
30
+ action: InterceptAction | 'auto_deny' | 'rate_limit';
31
+ outcome: 'approved' | 'denied' | 'auto_denied' | 'logged' | 'warned' | 'failure_allowed' | 'failure_denied';
32
+ preview: string;
33
+ ts: string;
34
+ }
35
+
36
+ const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'] as const;
37
+
38
+ const CONTENT_FIELDS: Record<string, string[]> = {
39
+ remember: ['content', 'title'],
40
+ mcp__memory__remember: ['content', 'title'],
41
+ };
42
+
43
+ const DEFAULT_CONFIG: InterceptorConfig = {
44
+ enabled: true,
45
+ severityActions: {
46
+ low: 'log',
47
+ medium: 'warn',
48
+ high: 'require_approval',
49
+ critical: 'require_approval',
50
+ },
51
+ failurePolicy: {
52
+ low: 'allow',
53
+ medium: 'allow',
54
+ high: 'deny',
55
+ critical: 'deny',
56
+ },
57
+ };
58
+
59
+ export { WATCHED_TOOLS, CONTENT_FIELDS, DEFAULT_CONFIG };
60
+
61
+ export function extractContent(toolName: string, args: Record<string, unknown>): { title: string; content: string } {
62
+ const fields = CONTENT_FIELDS[toolName];
63
+ if (!fields) return { title: '', content: '' };
64
+ const title = typeof args.title === 'string' ? args.title : '';
65
+ const content = typeof args.content === 'string' ? args.content : '';
66
+ return { title, content };
67
+ }
68
+
69
+ interface FirewallResult {
70
+ result: 'ALLOW' | 'BLOCK' | 'QUARANTINE';
71
+ anomalyScore: number;
72
+ }
73
+
74
+ export function mapSeverity(firewall: FirewallResult): Severity {
75
+ if (firewall.result === 'BLOCK') return 'critical';
76
+ if (firewall.result === 'QUARANTINE') return 'high';
77
+ if (firewall.result === 'ALLOW' && firewall.anomalyScore >= 0.3) return 'medium';
78
+ return 'low';
79
+ }
80
+
81
+ // --- Deny Cache ---
82
+
83
+ // Exact replica of normalizeMemoryText() from index.ts (lines 426-434).
84
+ // Must produce identical output for SHA-256 hash consistency.
85
+ function normaliseContent(text: string): string {
86
+ return String(text || '')
87
+ .toLowerCase()
88
+ .replace(/[`"'\\]/g, ' ')
89
+ .replace(/https?:\/\/\S+/g, ' ')
90
+ .replace(/[^a-z0-9\s]/g, ' ')
91
+ .replace(/\s+/g, ' ')
92
+ .trim();
93
+ }
94
+
95
+ function hashContent(text: string): string {
96
+ return createHash('sha256').update(normaliseContent(text)).digest('hex');
97
+ }
98
+
99
+ interface DenyCacheEntry {
100
+ hash: string;
101
+ ts: number;
102
+ }
103
+
104
+ const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
105
+
106
+ export class DenyCache {
107
+ private cache = new Map<string, DenyCacheEntry[]>();
108
+ private maxPerTool: number;
109
+ private ttlMs: number;
110
+
111
+ constructor(maxPerTool = 200, ttlMs = TWO_HOURS_MS) {
112
+ this.maxPerTool = maxPerTool;
113
+ this.ttlMs = ttlMs;
114
+ }
115
+
116
+ isDenied(tool: string, content: string): boolean {
117
+ const entries = this.cache.get(tool);
118
+ if (!entries) return false;
119
+ const hash = hashContent(content);
120
+ const now = Date.now();
121
+ return entries.some(e => e.hash === hash && (now - e.ts) < this.ttlMs);
122
+ }
123
+
124
+ addDenial(tool: string, content: string): void {
125
+ const hash = hashContent(content);
126
+ const now = Date.now();
127
+ if (!this.cache.has(tool)) {
128
+ this.cache.set(tool, []);
129
+ }
130
+ const entries = this.cache.get(tool)!;
131
+ const live = entries.filter(e => (now - e.ts) < this.ttlMs);
132
+ if (live.some(e => e.hash === hash)) return;
133
+ live.push({ hash, ts: now });
134
+ while (live.length > this.maxPerTool) {
135
+ live.shift();
136
+ }
137
+ this.cache.set(tool, live);
138
+ }
139
+
140
+ reset(): void {
141
+ this.cache.clear();
142
+ }
143
+ }
144
+
145
+ // --- Rate Limiter ---
146
+
147
+ export class RateLimiter {
148
+ private timestamps: number[] = [];
149
+ private maxPerWindow: number;
150
+ private windowMs: number;
151
+
152
+ constructor(maxPerWindow = 5, windowMs = 60_000) {
153
+ this.maxPerWindow = maxPerWindow;
154
+ this.windowMs = windowMs;
155
+ }
156
+
157
+ shouldAllow(): boolean {
158
+ const now = Date.now();
159
+ this.timestamps = this.timestamps.filter(t => now - t < this.windowMs);
160
+ if (this.timestamps.length >= this.maxPerWindow) return false;
161
+ this.timestamps.push(now);
162
+ return true;
163
+ }
164
+ }
165
+
166
+ // --- Approval Prompt ---
167
+
168
+ interface ApprovalPromptInput {
169
+ tool: string;
170
+ severity: Severity;
171
+ firewallResult: string;
172
+ threats: string[];
173
+ content: string;
174
+ }
175
+
176
+ export function formatApprovalPrompt(input: ApprovalPromptInput): string {
177
+ const preview = input.content.length > 200
178
+ ? input.content.slice(0, 200) + '...'
179
+ : input.content;
180
+ const threatList = input.threats.length > 0
181
+ ? input.threats.join(', ')
182
+ : 'none identified';
183
+
184
+ return [
185
+ '🛡️ ShieldCortex — Tool Call Intercepted',
186
+ '',
187
+ `Tool: ${input.tool}`,
188
+ `Risk: ${input.severity} (${input.firewallResult})`,
189
+ `Threats: ${threatList}`,
190
+ `Content: "${preview}"`,
191
+ '',
192
+ '[Approve] [Deny]',
193
+ ].join('\n');
194
+ }
195
+
196
+ // --- Audit Logging (local JSONL) ---
197
+
198
+ const AUDIT_DIR = join(homedir(), '.shieldcortex', 'audit');
199
+
200
+ function writeAuditEntry(entry: InterceptAuditEntry): void {
201
+ try {
202
+ mkdirSync(AUDIT_DIR, { recursive: true });
203
+ const date = new Date().toISOString().slice(0, 10);
204
+ const file = join(AUDIT_DIR, `realtime-${date}.jsonl`);
205
+ appendFileSync(file, JSON.stringify(entry) + '\n');
206
+ } catch {
207
+ // Best-effort — never block on audit failure
208
+ }
209
+ }
210
+
211
+ // --- X-Ray Inline Guard ---
212
+ // Lightweight inline version of xrayMemoryContent for the plugin build boundary.
213
+ // Detects AI directive injection patterns in memory content.
214
+
215
+ const XRAY_AI_DIRECTIVE_PATTERNS: RegExp[] = [
216
+ /ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|prompts?|context)/i,
217
+ /disregard\s+(all\s+)?(previous|prior|above)\s+(instructions?|rules?)/i,
218
+ /override\s+(previous|prior|all)\s+(instructions?|rules?|constraints?)/i,
219
+ /you\s+are\s+now\s+(?:in\s+)?(?:developer|god|admin|root|unrestricted)\s+mode/i,
220
+ /enter\s+(?:developer|god|admin|DAN|jailbreak)\s+mode/i,
221
+ /(?:system|hidden|secret)\s*(?:prompt|instruction|directive)\s*:/i,
222
+ /\[SYSTEM\]\s*:/i,
223
+ /\[INST\]/i,
224
+ /<\|(?:system|user|assistant|im_start|im_end)\|>/i,
225
+ /(?:decode|execute|follow)\s+(?:the\s+)?hidden\s+(?:instructions?|payload|message)/i,
226
+ /(?:hidden|embedded|encoded)\s+(?:instructions?|directive|command)\s+(?:in|within|inside)/i,
227
+ ];
228
+
229
+ const XRAY_FILENAME_PATTERNS: RegExp[] = [
230
+ /ignore_previous/i, /decode_hidden/i, /execute_instructions/i,
231
+ /override_previous/i, /developer_mode/i, /system_prompt/i,
232
+ /jailbreak/i, /\[SYSTEM\]/i, /\[INST\]/i,
233
+ ];
234
+
235
+ interface XRayGuardResult {
236
+ allowed: boolean;
237
+ findings: Array<{ category: string; title: string; severity: string }>;
238
+ riskLevel: string;
239
+ }
240
+
241
+ function xrayMemoryGuard(content: string, title?: string): XRayGuardResult {
242
+ const findings: Array<{ category: string; title: string; severity: string }> = [];
243
+ const text = content.length > 50000 ? content.slice(0, 50000) : content;
244
+
245
+ for (const pattern of XRAY_AI_DIRECTIVE_PATTERNS) {
246
+ if (pattern.test(text)) {
247
+ findings.push({ category: 'ai-directive', title: 'AI directive injection detected', severity: 'critical' });
248
+ break;
249
+ }
250
+ }
251
+
252
+ if (title) {
253
+ for (const pattern of XRAY_FILENAME_PATTERNS) {
254
+ if (pattern.test(title)) {
255
+ findings.push({ category: 'ai-directive', title: 'AI directive in title', severity: 'critical' });
256
+ break;
257
+ }
258
+ }
259
+ }
260
+
261
+ // Score: 100 - 60 per critical finding (single critical = blocked)
262
+ const score = Math.max(0, 100 - findings.length * 60);
263
+ const riskLevel = score >= 80 ? 'SAFE' : score >= 60 ? 'LOW' : score >= 40 ? 'MEDIUM' : score >= 20 ? 'HIGH' : 'CRITICAL';
264
+ return { allowed: score >= 60, findings, riskLevel };
265
+ }
266
+
267
+ // --- Interceptor Factory ---
268
+
269
+ type PipelineRunner = (content: string, title: string, source: { type: string; identifier: string }) => {
270
+ allowed: boolean;
271
+ firewall: {
272
+ result: 'ALLOW' | 'BLOCK' | 'QUARANTINE';
273
+ reason: string;
274
+ threatIndicators: string[];
275
+ anomalyScore: number;
276
+ blockedPatterns: string[];
277
+ };
278
+ auditId: number;
279
+ };
280
+
281
+ interface InterceptorOptions {
282
+ maxPromptsPerMinute?: number;
283
+ onAuditEntry?: (entry: InterceptAuditEntry) => void;
284
+ }
285
+
286
+ export function createInterceptor(
287
+ config: InterceptorConfig,
288
+ pipeline: PipelineRunner,
289
+ options?: InterceptorOptions,
290
+ ): {
291
+ handleToolCall: (context: ToolCallContext) => Promise<void>;
292
+ resetSession: () => void;
293
+ } {
294
+ const denyCache = new DenyCache();
295
+ const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
296
+ const log = config.logger ?? { info: console.log, warn: console.warn };
297
+ const onAuditEntry = options?.onAuditEntry;
298
+
299
+ function emitAudit(entry: InterceptAuditEntry): void {
300
+ writeAuditEntry(entry);
301
+ onAuditEntry?.(entry);
302
+ }
303
+
304
+ async function handleToolCall(context: ToolCallContext): Promise<void> {
305
+ if (!(WATCHED_TOOLS as readonly string[]).includes(context.toolName)) return;
306
+
307
+ const { title, content } = extractContent(context.toolName, context.arguments);
308
+ const fullContent = [title, content].filter(Boolean).join(' ');
309
+ if (!fullContent.trim()) return;
310
+
311
+ // X-Ray content scan — fast, synchronous, no I/O
312
+ const xrayResult = xrayMemoryGuard(content, title || undefined);
313
+ if (!xrayResult.allowed) {
314
+ const xrayEntry: InterceptAuditEntry = {
315
+ type: 'intercept', tool: context.toolName, severity: 'critical',
316
+ firewallResult: 'BLOCK', threats: xrayResult.findings.map(f => f.category),
317
+ anomalyScore: 1, action: 'auto_deny', outcome: 'auto_denied',
318
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
319
+ };
320
+ emitAudit(xrayEntry);
321
+ throw new Error(`ShieldCortex: tool call blocked by X-Ray memory guard (risk: ${xrayResult.riskLevel}, findings: ${xrayResult.findings.length})`);
322
+ }
323
+
324
+ let severity: Severity;
325
+ let firewallResult: string;
326
+ let threats: string[];
327
+ let anomalyScore: number;
328
+
329
+ try {
330
+ const result = pipeline(content, title, { type: 'agent', identifier: 'openclaw' });
331
+ severity = mapSeverity(result.firewall);
332
+ firewallResult = result.firewall.result;
333
+ threats = result.firewall.threatIndicators;
334
+ anomalyScore = result.firewall.anomalyScore;
335
+ } catch (err) {
336
+ log.warn(`[shieldcortex] ⚠️ Defence pipeline error: ${err instanceof Error ? err.message : err}`);
337
+ const failAction = config.failurePolicy.high;
338
+ const entry: InterceptAuditEntry = {
339
+ type: 'intercept', tool: context.toolName, severity: 'high',
340
+ firewallResult: 'ERROR', threats: ['pipeline_error'], anomalyScore: 0,
341
+ action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed',
342
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
343
+ };
344
+ emitAudit(entry);
345
+ if (failAction === 'deny') {
346
+ throw new Error('ShieldCortex: tool call blocked — pipeline error, failure policy: deny');
347
+ }
348
+ return;
349
+ }
350
+
351
+ if (denyCache.isDenied(context.toolName, fullContent)) {
352
+ const entry: InterceptAuditEntry = {
353
+ type: 'intercept', tool: context.toolName, severity, firewallResult,
354
+ threats, anomalyScore, action: 'auto_deny', outcome: 'auto_denied',
355
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
356
+ };
357
+ emitAudit(entry);
358
+ throw new Error('ShieldCortex: tool call auto-denied (previously denied content)');
359
+ }
360
+
361
+ const action = config.severityActions[severity];
362
+
363
+ if (action === 'log') {
364
+ const entry: InterceptAuditEntry = {
365
+ type: 'intercept', tool: context.toolName, severity, firewallResult,
366
+ threats, anomalyScore, action: 'log', outcome: 'logged',
367
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
368
+ };
369
+ emitAudit(entry);
370
+ return;
371
+ }
372
+
373
+ if (action === 'warn') {
374
+ log.warn(`[shieldcortex] ⚠️ ${severity} risk in ${context.toolName}: ${threats.join(', ') || 'anomaly detected'}`);
375
+ const entry: InterceptAuditEntry = {
376
+ type: 'intercept', tool: context.toolName, severity, firewallResult,
377
+ threats, anomalyScore, action: 'warn', outcome: 'warned',
378
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
379
+ };
380
+ emitAudit(entry);
381
+ return;
382
+ }
383
+
384
+ // action === 'require_approval'
385
+ if (typeof context.requireApproval !== 'function') {
386
+ // requireApproval unavailable (pre-v2026.3.28) — apply failurePolicy, not blanket allow
387
+ const failAction = config.failurePolicy[severity];
388
+ log.warn(`[shieldcortex] ⚠️ requireApproval not available for ${severity} risk in ${context.toolName} — failure policy: ${failAction}`);
389
+ const entry: InterceptAuditEntry = {
390
+ type: 'intercept', tool: context.toolName, severity, firewallResult,
391
+ threats, anomalyScore, action: 'require_approval',
392
+ outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed',
393
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
394
+ };
395
+ emitAudit(entry);
396
+ if (failAction === 'deny') {
397
+ throw new Error(`ShieldCortex: tool call blocked — requireApproval unavailable, failure policy: deny`);
398
+ }
399
+ return;
400
+ }
401
+
402
+ if (!rateLimiter.shouldAllow()) {
403
+ log.warn('[shieldcortex] ⚠️ Too many approval prompts — auto-denying');
404
+ const entry: InterceptAuditEntry = {
405
+ type: 'intercept', tool: context.toolName, severity, firewallResult,
406
+ threats, anomalyScore, action: 'rate_limit', outcome: 'auto_denied',
407
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
408
+ };
409
+ emitAudit(entry);
410
+ denyCache.addDenial(context.toolName, fullContent);
411
+ throw new Error('ShieldCortex: tool call auto-denied (rate limit exceeded)');
412
+ }
413
+
414
+ const message = formatApprovalPrompt({ tool: context.toolName, severity, firewallResult, threats, content: fullContent });
415
+
416
+ let approved: boolean;
417
+ try {
418
+ approved = await context.requireApproval(message);
419
+ } catch (err) {
420
+ const failAction = config.failurePolicy[severity];
421
+ log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
422
+ const entry: InterceptAuditEntry = {
423
+ type: 'intercept', tool: context.toolName, severity, firewallResult,
424
+ threats, anomalyScore, action: 'require_approval',
425
+ outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed',
426
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
427
+ };
428
+ emitAudit(entry);
429
+ if (failAction === 'deny') {
430
+ throw new Error(`ShieldCortex: tool call blocked — requireApproval error, failure policy: deny`);
431
+ }
432
+ return;
433
+ }
434
+
435
+ if (approved) {
436
+ const entry: InterceptAuditEntry = {
437
+ type: 'intercept', tool: context.toolName, severity, firewallResult,
438
+ threats, anomalyScore, action: 'require_approval', outcome: 'approved',
439
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
440
+ };
441
+ emitAudit(entry);
442
+ return;
443
+ }
444
+
445
+ // Denied
446
+ denyCache.addDenial(context.toolName, fullContent);
447
+ const entry: InterceptAuditEntry = {
448
+ type: 'intercept', tool: context.toolName, severity, firewallResult,
449
+ threats, anomalyScore, action: 'require_approval', outcome: 'denied',
450
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
451
+ };
452
+ emitAudit(entry);
453
+ throw new Error('ShieldCortex: tool call denied by user');
454
+ }
455
+
456
+ function resetSession(): void {
457
+ denyCache.reset();
458
+ }
459
+
460
+ return { handleToolCall, resetSession };
461
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "3.4.4",
3
+ "version": "4.8.0",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
- "description": "Real-time defence scanning on LLM input and memory extraction on LLM output.",
5
+ "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "uiHints": {
7
7
  "binaryPath": {
8
8
  "label": "ShieldCortex Binary Path",
@@ -40,6 +40,21 @@
40
40
  "label": "Recent Memory Cache Size",
41
41
  "help": "How many recent extracted memories to keep in the dedupe cache.",
42
42
  "advanced": true
43
+ },
44
+ "interceptor.enabled": {
45
+ "label": "Enable Tool Call Interceptor",
46
+ "description": "Scan memory-write tool calls and gate suspicious content behind user approval",
47
+ "type": "boolean"
48
+ },
49
+ "interceptor.severityActions.high": {
50
+ "label": "High Severity Action",
51
+ "description": "Action for high-severity threats (log, warn, require_approval)",
52
+ "type": "string"
53
+ },
54
+ "interceptor.severityActions.critical": {
55
+ "label": "Critical Severity Action",
56
+ "description": "Action for critical-severity threats (log, warn, require_approval)",
57
+ "type": "string"
43
58
  }
44
59
  },
45
60
  "configSchema": {
@@ -73,6 +88,32 @@
73
88
  "type": "integer",
74
89
  "minimum": 50,
75
90
  "maximum": 1000
91
+ },
92
+ "interceptor": {
93
+ "type": "object",
94
+ "properties": {
95
+ "enabled": { "type": "boolean", "default": true },
96
+ "severityActions": {
97
+ "type": "object",
98
+ "additionalProperties": false,
99
+ "properties": {
100
+ "low": { "type": "string", "enum": ["log", "warn", "require_approval"], "default": "log" },
101
+ "medium": { "type": "string", "enum": ["log", "warn", "require_approval"], "default": "warn" },
102
+ "high": { "type": "string", "enum": ["log", "warn", "require_approval"], "default": "require_approval" },
103
+ "critical": { "type": "string", "enum": ["log", "warn", "require_approval"], "default": "require_approval" }
104
+ }
105
+ },
106
+ "failurePolicy": {
107
+ "type": "object",
108
+ "additionalProperties": false,
109
+ "properties": {
110
+ "low": { "type": "string", "enum": ["allow", "deny"], "default": "allow" },
111
+ "medium": { "type": "string", "enum": ["allow", "deny"], "default": "allow" },
112
+ "high": { "type": "string", "enum": ["allow", "deny"], "default": "deny" },
113
+ "critical": { "type": "string", "enum": ["allow", "deny"], "default": "deny" }
114
+ }
115
+ }
116
+ }
76
117
  }
77
118
  }
78
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "3.4.4",
3
+ "version": "4.8.0",
4
4
  "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -15,6 +15,8 @@
15
15
  ],
16
16
  "files": [
17
17
  "index.ts",
18
+ "interceptor.ts",
19
+ "intercept-ingest.ts",
18
20
  "openclaw.plugin.json",
19
21
  "README.md"
20
22
  ],
@@ -22,7 +24,10 @@
22
24
  "pack:verify": "npm pack --dry-run"
23
25
  },
24
26
  "peerDependencies": {
25
- "shieldcortex": ">=3.4.4"
27
+ "shieldcortex": "^4.6.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=18.0.0"
26
31
  },
27
32
  "publishConfig": {
28
33
  "access": "public"