@drakon-systems/shieldcortex-realtime 4.12.14 → 4.14.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/dist/cloud-sync.js +19 -0
- package/dist/index.js +703 -0
- package/dist/intercept-ingest.js +18 -0
- package/dist/interceptor.js +346 -0
- package/dist/openclaw.plugin.json +135 -0
- package/index.ts +12 -6
- package/openclaw.plugin.json +1 -1
- package/package.json +11 -4
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// plugins/openclaw/cloud-sync.ts
|
|
2
|
+
//
|
|
3
|
+
// Network egress for SC threat events. See CHANGELOG.md v4.12.8 / v4.12.9.
|
|
4
|
+
export function cloudSync(threat, cfg) {
|
|
5
|
+
if (!cfg.cloudApiKey)
|
|
6
|
+
return;
|
|
7
|
+
const url = `${cfg.cloudBaseUrl || 'https://api.shieldcortex.ai'}/v1/threats`;
|
|
8
|
+
fetch(url, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: {
|
|
11
|
+
'Content-Type': 'application/json',
|
|
12
|
+
Authorization: `Bearer ${cfg.cloudApiKey}`,
|
|
13
|
+
},
|
|
14
|
+
body: JSON.stringify(threat),
|
|
15
|
+
signal: AbortSignal.timeout(5000),
|
|
16
|
+
}).catch(() => {
|
|
17
|
+
// Fire-and-forget — never block on cloud sync failure
|
|
18
|
+
});
|
|
19
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShieldCortex Real-time Scanning Plugin for OpenClaw v2026.3.22+
|
|
3
|
+
*
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import fs from "node:fs/promises";
|
|
10
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
14
|
+
import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
|
|
15
|
+
import { syncInterceptEvent } from './intercept-ingest.js';
|
|
16
|
+
import { cloudSync } from './cloud-sync.js';
|
|
17
|
+
let runtimePromise = null;
|
|
18
|
+
function addRuntimeCandidate(candidates, packageRoot) {
|
|
19
|
+
const runtimePath = path.join(packageRoot, "hooks", "openclaw", "cortex-memory", "runtime.mjs");
|
|
20
|
+
if (existsSync(runtimePath)) {
|
|
21
|
+
candidates.add(pathToFileURL(runtimePath).href);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function addAncestorCandidates(candidates, startPath) {
|
|
25
|
+
let current = path.resolve(startPath);
|
|
26
|
+
let previous = "";
|
|
27
|
+
for (let i = 0; i < 6 && current !== previous; i++) {
|
|
28
|
+
addRuntimeCandidate(candidates, current);
|
|
29
|
+
previous = current;
|
|
30
|
+
current = path.dirname(current);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function collectRuntimeCandidates() {
|
|
34
|
+
const candidates = new Set();
|
|
35
|
+
// 1. Relative path (works when running from within npm package tree)
|
|
36
|
+
candidates.add(new URL("../../hooks/openclaw/cortex-memory/runtime.mjs", import.meta.url).href);
|
|
37
|
+
// 2. Config file override (reads path from ~/.shieldcortex/config.json instead of env var)
|
|
38
|
+
try {
|
|
39
|
+
const cfgPath = path.join(homedir(), ".shieldcortex", "config.json");
|
|
40
|
+
if (existsSync(cfgPath)) {
|
|
41
|
+
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
42
|
+
if (cfg.installRoot)
|
|
43
|
+
addRuntimeCandidate(candidates, cfg.installRoot);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch { /* no config */ }
|
|
47
|
+
// 3. Walk up from current file location
|
|
48
|
+
addAncestorCandidates(candidates, path.dirname(fileURLToPath(import.meta.url)));
|
|
49
|
+
// 4. Resolve via common bin symlink paths (no child_process needed)
|
|
50
|
+
for (const binDir of ["/usr/local/bin", "/opt/homebrew/bin", path.join(homedir(), ".npm-global", "bin")]) {
|
|
51
|
+
const binPath = path.join(binDir, "shieldcortex");
|
|
52
|
+
try {
|
|
53
|
+
if (existsSync(binPath))
|
|
54
|
+
addAncestorCandidates(candidates, realpathSync(binPath));
|
|
55
|
+
}
|
|
56
|
+
catch { /* broken symlink */ }
|
|
57
|
+
}
|
|
58
|
+
// 5. Common global install paths (covers npm root -g results without spawning npm)
|
|
59
|
+
for (const root of [
|
|
60
|
+
"/usr/lib/node_modules/shieldcortex",
|
|
61
|
+
"/usr/local/lib/node_modules/shieldcortex",
|
|
62
|
+
"/opt/homebrew/lib/node_modules/shieldcortex",
|
|
63
|
+
path.join(homedir(), ".npm-global", "lib", "node_modules", "shieldcortex"),
|
|
64
|
+
path.join(homedir(), ".nvm", "versions", "node"), // nvm users
|
|
65
|
+
]) {
|
|
66
|
+
if (root.includes(".nvm")) {
|
|
67
|
+
// For nvm, check the current symlink
|
|
68
|
+
try {
|
|
69
|
+
const currentNode = path.join(homedir(), ".nvm", "current", "lib", "node_modules", "shieldcortex");
|
|
70
|
+
addRuntimeCandidate(candidates, currentNode);
|
|
71
|
+
}
|
|
72
|
+
catch { /* no nvm */ }
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
addRuntimeCandidate(candidates, root);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return [...candidates];
|
|
79
|
+
}
|
|
80
|
+
async function getRuntime() {
|
|
81
|
+
if (!runtimePromise) {
|
|
82
|
+
runtimePromise = (async () => {
|
|
83
|
+
const tried = [];
|
|
84
|
+
let lastError = null;
|
|
85
|
+
for (const candidate of collectRuntimeCandidates()) {
|
|
86
|
+
tried.push(candidate);
|
|
87
|
+
try {
|
|
88
|
+
const mod = await import(candidate);
|
|
89
|
+
if (typeof mod.createOpenClawRuntime === "function") {
|
|
90
|
+
return mod.createOpenClawRuntime({ logPrefix: "[shieldcortex]" });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
lastError = error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const detail = lastError instanceof Error ? lastError.message : String(lastError ?? "unknown error");
|
|
98
|
+
throw new Error(`Could not load OpenClaw runtime. Tried: ${tried.join(", ")}. Last error: ${detail}`);
|
|
99
|
+
})();
|
|
100
|
+
}
|
|
101
|
+
return runtimePromise;
|
|
102
|
+
}
|
|
103
|
+
const PLUGIN_ID = "shieldcortex-realtime";
|
|
104
|
+
const PLUGIN_PACKAGE_NAME = "@drakon-systems/shieldcortex-realtime";
|
|
105
|
+
const PLUGIN_CONFIG_UI_HINTS = {
|
|
106
|
+
binaryPath: {
|
|
107
|
+
label: "ShieldCortex Binary Path",
|
|
108
|
+
help: "Optional absolute path to the shieldcortex CLI when it is not on PATH.",
|
|
109
|
+
placeholder: "/usr/local/bin/shieldcortex",
|
|
110
|
+
advanced: true,
|
|
111
|
+
},
|
|
112
|
+
cloudApiKey: {
|
|
113
|
+
label: "Cloud API Key",
|
|
114
|
+
help: "Optional ShieldCortex Cloud API key used for realtime threat forwarding.",
|
|
115
|
+
sensitive: true,
|
|
116
|
+
placeholder: "sc_...",
|
|
117
|
+
},
|
|
118
|
+
cloudBaseUrl: {
|
|
119
|
+
label: "Cloud Base URL",
|
|
120
|
+
help: "Override the ShieldCortex Cloud API base URL if you use a self-hosted or staging endpoint.",
|
|
121
|
+
placeholder: "https://api.shieldcortex.ai",
|
|
122
|
+
advanced: true,
|
|
123
|
+
},
|
|
124
|
+
openclawAutoMemory: {
|
|
125
|
+
label: "Auto Memory Extraction",
|
|
126
|
+
help: "Extract high-signal decisions and learnings from LLM output into ShieldCortex memory.",
|
|
127
|
+
},
|
|
128
|
+
openclawAutoMemoryDedupe: {
|
|
129
|
+
label: "Dedupe Auto Memory",
|
|
130
|
+
help: "Skip near-duplicate memories before they are written to ShieldCortex.",
|
|
131
|
+
advanced: true,
|
|
132
|
+
},
|
|
133
|
+
openclawAutoMemoryNoveltyThreshold: {
|
|
134
|
+
label: "Novelty Threshold",
|
|
135
|
+
help: "Similarity threshold for duplicate suppression. Higher values keep more memories.",
|
|
136
|
+
advanced: true,
|
|
137
|
+
},
|
|
138
|
+
openclawAutoMemoryMaxRecent: {
|
|
139
|
+
label: "Recent Memory Cache Size",
|
|
140
|
+
help: "How many recent extracted memories to keep in the dedupe cache.",
|
|
141
|
+
advanced: true,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
const PLUGIN_CONFIG_JSON_SCHEMA = {
|
|
145
|
+
type: "object",
|
|
146
|
+
additionalProperties: false,
|
|
147
|
+
properties: {
|
|
148
|
+
enabled: { type: "boolean" },
|
|
149
|
+
binaryPath: { type: "string" },
|
|
150
|
+
cloudApiKey: { type: "string" },
|
|
151
|
+
cloudBaseUrl: { type: "string" },
|
|
152
|
+
openclawAutoMemory: { type: "boolean" },
|
|
153
|
+
openclawAutoMemoryDedupe: { type: "boolean" },
|
|
154
|
+
openclawAutoMemoryNoveltyThreshold: { type: "number", minimum: 0.6, maximum: 0.99 },
|
|
155
|
+
openclawAutoMemoryMaxRecent: { type: "integer", minimum: 50, maximum: 1000 },
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
let _config = null;
|
|
159
|
+
// Identity of the shield config we last merged from. The runtime's
|
|
160
|
+
// loadShieldConfig() returns the same parsed object until the file's mtime
|
|
161
|
+
// advances; using reference equality lets us re-merge precisely when the
|
|
162
|
+
// underlying config has actually changed (dashboard / CLI write).
|
|
163
|
+
let _lastShieldConfigRef = null;
|
|
164
|
+
let _configOverride = null;
|
|
165
|
+
let _version = "0.0.0";
|
|
166
|
+
try {
|
|
167
|
+
// Try package.json first, then openclaw.plugin.json (the manifest IS copied to extensions/)
|
|
168
|
+
for (const candidateUrl of [
|
|
169
|
+
new URL("./package.json", import.meta.url),
|
|
170
|
+
new URL("../../package.json", import.meta.url),
|
|
171
|
+
new URL("./openclaw.plugin.json", import.meta.url),
|
|
172
|
+
]) {
|
|
173
|
+
try {
|
|
174
|
+
const data = JSON.parse(readFileSync(candidateUrl, "utf-8"));
|
|
175
|
+
if (typeof data.version === "string" && data.version.trim()) {
|
|
176
|
+
_version = data.version;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// try the next candidate
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch { /* fallback */ }
|
|
186
|
+
let _registered = false;
|
|
187
|
+
function normaliseConfig(raw) {
|
|
188
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
189
|
+
return {};
|
|
190
|
+
const value = raw;
|
|
191
|
+
const config = {};
|
|
192
|
+
if (typeof value.cloudApiKey === "string" && value.cloudApiKey.trim()) {
|
|
193
|
+
config.cloudApiKey = value.cloudApiKey.trim();
|
|
194
|
+
}
|
|
195
|
+
if (typeof value.cloudBaseUrl === "string" && value.cloudBaseUrl.trim()) {
|
|
196
|
+
config.cloudBaseUrl = value.cloudBaseUrl.trim();
|
|
197
|
+
}
|
|
198
|
+
if (typeof value.binaryPath === "string" && value.binaryPath.trim()) {
|
|
199
|
+
config.binaryPath = value.binaryPath.trim();
|
|
200
|
+
}
|
|
201
|
+
if (typeof value.openclawAutoMemory === "boolean") {
|
|
202
|
+
config.openclawAutoMemory = value.openclawAutoMemory;
|
|
203
|
+
}
|
|
204
|
+
if (typeof value.openclawAutoMemoryDedupe === "boolean") {
|
|
205
|
+
config.openclawAutoMemoryDedupe = value.openclawAutoMemoryDedupe;
|
|
206
|
+
}
|
|
207
|
+
if (typeof value.openclawAutoMemoryNoveltyThreshold === "number" && !Number.isNaN(value.openclawAutoMemoryNoveltyThreshold)) {
|
|
208
|
+
config.openclawAutoMemoryNoveltyThreshold = clamp(value.openclawAutoMemoryNoveltyThreshold, 0.6, 0.99);
|
|
209
|
+
}
|
|
210
|
+
if (typeof value.openclawAutoMemoryMaxRecent === "number" && !Number.isNaN(value.openclawAutoMemoryMaxRecent)) {
|
|
211
|
+
config.openclawAutoMemoryMaxRecent = Math.floor(clamp(value.openclawAutoMemoryMaxRecent, 50, 1000));
|
|
212
|
+
}
|
|
213
|
+
return config;
|
|
214
|
+
}
|
|
215
|
+
function extractPluginConfig(rootConfig) {
|
|
216
|
+
if (!rootConfig || typeof rootConfig !== "object" || Array.isArray(rootConfig))
|
|
217
|
+
return {};
|
|
218
|
+
const entries = rootConfig.plugins?.entries;
|
|
219
|
+
const pluginConfig = entries?.[PLUGIN_ID]?.config ??
|
|
220
|
+
entries?.[PLUGIN_PACKAGE_NAME]?.config;
|
|
221
|
+
return normaliseConfig(pluginConfig);
|
|
222
|
+
}
|
|
223
|
+
function applyPluginConfigOverride(api) {
|
|
224
|
+
const runtimeConfigApi = api.runtime?.config;
|
|
225
|
+
const runtimeConfig = typeof runtimeConfigApi?.current === "function"
|
|
226
|
+
? runtimeConfigApi.current()
|
|
227
|
+
: typeof runtimeConfigApi?.loadConfig === "function"
|
|
228
|
+
? runtimeConfigApi.loadConfig()
|
|
229
|
+
: api.config;
|
|
230
|
+
const pluginConfig = extractPluginConfig(runtimeConfig);
|
|
231
|
+
if (Object.keys(pluginConfig).length === 0)
|
|
232
|
+
return;
|
|
233
|
+
_configOverride = {
|
|
234
|
+
...(_configOverride ?? {}),
|
|
235
|
+
...pluginConfig,
|
|
236
|
+
};
|
|
237
|
+
// Override changed — invalidate so loadConfig() re-merges with new override.
|
|
238
|
+
_config = null;
|
|
239
|
+
_lastShieldConfigRef = null;
|
|
240
|
+
}
|
|
241
|
+
async function loadConfig() {
|
|
242
|
+
const shieldConfigRaw = await (await getRuntime()).loadShieldConfig();
|
|
243
|
+
if (_config && shieldConfigRaw === _lastShieldConfigRef)
|
|
244
|
+
return _config;
|
|
245
|
+
_lastShieldConfigRef = shieldConfigRaw;
|
|
246
|
+
_config = {
|
|
247
|
+
...normaliseConfig(shieldConfigRaw),
|
|
248
|
+
...(_configOverride ?? {}),
|
|
249
|
+
};
|
|
250
|
+
return _config;
|
|
251
|
+
}
|
|
252
|
+
function isAutoMemoryEnabled(config) {
|
|
253
|
+
return config.openclawAutoMemory === true;
|
|
254
|
+
}
|
|
255
|
+
function isAutoMemoryDedupeEnabled(config) {
|
|
256
|
+
return config.openclawAutoMemoryDedupe !== false;
|
|
257
|
+
}
|
|
258
|
+
async function callCortex(tool, args = {}) {
|
|
259
|
+
return (await getRuntime()).callCortex(tool, args);
|
|
260
|
+
}
|
|
261
|
+
// ==================== REMOTE SCANNING ====================
|
|
262
|
+
async function scanRealtimeContent(text) {
|
|
263
|
+
const response = await callCortex("scan_tool_response", {
|
|
264
|
+
toolName: "openclaw-realtime",
|
|
265
|
+
content: text,
|
|
266
|
+
mode: "advisory",
|
|
267
|
+
});
|
|
268
|
+
if (!response) {
|
|
269
|
+
return { clean: true, summary: "scan unavailable" };
|
|
270
|
+
}
|
|
271
|
+
const cleanMatch = response.match(/\*\*Clean:\*\*\s*(Yes|No)/i);
|
|
272
|
+
const riskMatch = response.match(/\*\*Risk Level:\*\*\s*([A-Za-z]+)/i);
|
|
273
|
+
const detectionsMatch = response.match(/\*\*Detections:\*\*\s*(\d+)/i);
|
|
274
|
+
const clean = cleanMatch ? /yes/i.test(cleanMatch[1]) : true;
|
|
275
|
+
const risk = riskMatch?.[1] ?? "unknown";
|
|
276
|
+
const detections = detectionsMatch?.[1];
|
|
277
|
+
const summary = detections ? `${risk} (${detections} detections)` : risk;
|
|
278
|
+
return { clean, summary };
|
|
279
|
+
}
|
|
280
|
+
// ==================== CONTENT PATTERNS ====================
|
|
281
|
+
const PATTERNS = {
|
|
282
|
+
architecture: [/\b(?:architecture|designed|structured)\b.*?(?:uses?|is|with)\b/i, /\b(?:decided?\s+to|going\s+with|chose)\b/i],
|
|
283
|
+
error: [/\b(?:fixed|resolved|solved)\s+(?:by|with|using)\b/i, /\b(?:solution|fix|root\s*cause)\s+(?:was|is)\b/i],
|
|
284
|
+
learning: [/\b(?:learned|discovered|turns?\s+out|figured\s+out|realized)\b/i],
|
|
285
|
+
preference: [
|
|
286
|
+
/\b(?:I|we|you\s+should)\s+(?:always|never)\b/i,
|
|
287
|
+
/\b(?:always\s+use|never\s+use|never\s+commit)\b/i,
|
|
288
|
+
/\bprefer(?:\s+to)?\s+\w+/i,
|
|
289
|
+
/\bshould\s+always\b/i,
|
|
290
|
+
],
|
|
291
|
+
note: [/\b(?:important|remember|key\s+point)\s*:/i],
|
|
292
|
+
};
|
|
293
|
+
function extractMemories(texts) {
|
|
294
|
+
const out = [];
|
|
295
|
+
const seen = new Set();
|
|
296
|
+
for (const text of texts) {
|
|
297
|
+
if (text.length < 30)
|
|
298
|
+
continue;
|
|
299
|
+
for (const [cat, pats] of Object.entries(PATTERNS)) {
|
|
300
|
+
if (pats.some(p => p.test(text))) {
|
|
301
|
+
const title = text.slice(0, 80).replace(/["\n]/g, " ").trim();
|
|
302
|
+
if (!seen.has(title)) {
|
|
303
|
+
seen.add(title);
|
|
304
|
+
out.push({ title, content: text.slice(0, 500), category: cat });
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
if (out.length >= 3)
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
if (out.length >= 3)
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
// ==================== HELPERS ====================
|
|
317
|
+
function extractUserContent(msgs) {
|
|
318
|
+
const out = [];
|
|
319
|
+
for (const msg of msgs) {
|
|
320
|
+
if (!msg || typeof msg !== "object")
|
|
321
|
+
continue;
|
|
322
|
+
const m = msg;
|
|
323
|
+
if (m.role !== "user")
|
|
324
|
+
continue;
|
|
325
|
+
if (typeof m.content === "string")
|
|
326
|
+
out.push(m.content);
|
|
327
|
+
else if (Array.isArray(m.content))
|
|
328
|
+
for (const b of m.content)
|
|
329
|
+
if (b?.type === "text")
|
|
330
|
+
out.push(b.text);
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
const AUDIT_DIR = path.join(homedir(), ".shieldcortex", "audit");
|
|
335
|
+
const NOVELTY_CACHE_FILE = path.join(homedir(), ".shieldcortex", "openclaw-memory-cache.json");
|
|
336
|
+
const DEFAULT_NOVELTY_THRESHOLD = 0.88;
|
|
337
|
+
const DEFAULT_MAX_RECENT = 300;
|
|
338
|
+
const MIN_NOVELTY_CHARS = 40;
|
|
339
|
+
async function auditLog(entry) {
|
|
340
|
+
try {
|
|
341
|
+
await fs.mkdir(AUDIT_DIR, { recursive: true });
|
|
342
|
+
await fs.appendFile(path.join(AUDIT_DIR, `realtime-${new Date().toISOString().slice(0, 10)}.jsonl`), JSON.stringify(entry) + "\n");
|
|
343
|
+
}
|
|
344
|
+
catch { }
|
|
345
|
+
}
|
|
346
|
+
function normalizeMemoryText(text) {
|
|
347
|
+
return String(text || "")
|
|
348
|
+
.toLowerCase()
|
|
349
|
+
.replace(/[`"'\\]/g, " ")
|
|
350
|
+
.replace(/https?:\/\/\S+/g, " ")
|
|
351
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
352
|
+
.replace(/\s+/g, " ")
|
|
353
|
+
.trim();
|
|
354
|
+
}
|
|
355
|
+
function hashToken(token) {
|
|
356
|
+
return createHash("sha1").update(token).digest("hex").slice(0, 12);
|
|
357
|
+
}
|
|
358
|
+
function buildTokenHashes(normalized) {
|
|
359
|
+
const words = normalized.split(" ").filter((w) => w.length >= 3);
|
|
360
|
+
const set = new Set();
|
|
361
|
+
for (let i = 0; i < words.length; i++) {
|
|
362
|
+
set.add(hashToken(words[i]));
|
|
363
|
+
if (i < words.length - 1)
|
|
364
|
+
set.add(hashToken(`${words[i]}_${words[i + 1]}`));
|
|
365
|
+
}
|
|
366
|
+
return Array.from(set).slice(0, 200);
|
|
367
|
+
}
|
|
368
|
+
function jaccardSimilarity(a, b) {
|
|
369
|
+
if (a.size === 0 || b.size === 0)
|
|
370
|
+
return 0;
|
|
371
|
+
let intersection = 0;
|
|
372
|
+
for (const item of a) {
|
|
373
|
+
if (b.has(item))
|
|
374
|
+
intersection++;
|
|
375
|
+
}
|
|
376
|
+
const union = a.size + b.size - intersection;
|
|
377
|
+
return union === 0 ? 0 : intersection / union;
|
|
378
|
+
}
|
|
379
|
+
function clamp(value, min, max) {
|
|
380
|
+
return Math.max(min, Math.min(max, value));
|
|
381
|
+
}
|
|
382
|
+
async function loadNoveltyCache(maxRecent) {
|
|
383
|
+
try {
|
|
384
|
+
const raw = JSON.parse(await fs.readFile(NOVELTY_CACHE_FILE, "utf-8"));
|
|
385
|
+
if (!Array.isArray(raw))
|
|
386
|
+
return [];
|
|
387
|
+
return raw
|
|
388
|
+
.filter((entry) => entry && typeof entry.hash === "string" && Array.isArray(entry.tokenHashes))
|
|
389
|
+
.slice(0, maxRecent);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async function saveNoveltyCache(entries) {
|
|
396
|
+
await fs.mkdir(path.dirname(NOVELTY_CACHE_FILE), { recursive: true });
|
|
397
|
+
await fs.writeFile(NOVELTY_CACHE_FILE, JSON.stringify(entries, null, 2) + "\n", "utf-8");
|
|
398
|
+
}
|
|
399
|
+
function inspectNovelty(content, entries, threshold) {
|
|
400
|
+
const normalized = normalizeMemoryText(content);
|
|
401
|
+
if (normalized.length < MIN_NOVELTY_CHARS) {
|
|
402
|
+
return { allow: true, contentHash: null, tokenHashes: [] };
|
|
403
|
+
}
|
|
404
|
+
const contentHash = createHash("sha256").update(normalized).digest("hex").slice(0, 24);
|
|
405
|
+
if (entries.some((entry) => entry.hash === contentHash)) {
|
|
406
|
+
return { allow: false, contentHash, tokenHashes: [], reason: "exact duplicate" };
|
|
407
|
+
}
|
|
408
|
+
const tokenHashes = buildTokenHashes(normalized);
|
|
409
|
+
const currentSet = new Set(tokenHashes);
|
|
410
|
+
for (const entry of entries) {
|
|
411
|
+
const score = jaccardSimilarity(currentSet, new Set(entry.tokenHashes || []));
|
|
412
|
+
if (score >= threshold) {
|
|
413
|
+
return {
|
|
414
|
+
allow: false,
|
|
415
|
+
contentHash,
|
|
416
|
+
tokenHashes,
|
|
417
|
+
reason: `near duplicate (similarity ${score.toFixed(2)})`,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return { allow: true, contentHash, tokenHashes };
|
|
422
|
+
}
|
|
423
|
+
async function createNoveltyGate(config) {
|
|
424
|
+
const thresholdRaw = Number(config.openclawAutoMemoryNoveltyThreshold);
|
|
425
|
+
const maxRecentRaw = Number(config.openclawAutoMemoryMaxRecent);
|
|
426
|
+
const threshold = Number.isFinite(thresholdRaw)
|
|
427
|
+
? clamp(thresholdRaw, 0.6, 0.99)
|
|
428
|
+
: DEFAULT_NOVELTY_THRESHOLD;
|
|
429
|
+
const maxRecent = Number.isFinite(maxRecentRaw)
|
|
430
|
+
? Math.floor(clamp(maxRecentRaw, 50, 1000))
|
|
431
|
+
: DEFAULT_MAX_RECENT;
|
|
432
|
+
const enabled = isAutoMemoryDedupeEnabled(config);
|
|
433
|
+
const entries = enabled ? await loadNoveltyCache(maxRecent) : [];
|
|
434
|
+
let dirty = false;
|
|
435
|
+
return {
|
|
436
|
+
inspect(content) {
|
|
437
|
+
if (!enabled)
|
|
438
|
+
return { allow: true, contentHash: null, tokenHashes: [] };
|
|
439
|
+
return inspectNovelty(content, entries, threshold);
|
|
440
|
+
},
|
|
441
|
+
remember(memory, novelty) {
|
|
442
|
+
if (!enabled || !novelty.contentHash || novelty.tokenHashes.length === 0)
|
|
443
|
+
return;
|
|
444
|
+
entries.unshift({
|
|
445
|
+
hash: novelty.contentHash,
|
|
446
|
+
tokenHashes: novelty.tokenHashes,
|
|
447
|
+
title: String(memory.title || "").slice(0, 120),
|
|
448
|
+
category: String(memory.category || "note"),
|
|
449
|
+
createdAt: new Date().toISOString(),
|
|
450
|
+
});
|
|
451
|
+
if (entries.length > maxRecent)
|
|
452
|
+
entries.length = maxRecent;
|
|
453
|
+
dirty = true;
|
|
454
|
+
},
|
|
455
|
+
async flush() {
|
|
456
|
+
if (!enabled || !dirty)
|
|
457
|
+
return;
|
|
458
|
+
await saveNoveltyCache(entries);
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
// ==================== HOOK HANDLERS ====================
|
|
463
|
+
// Skip scanning internal OpenClaw content (boot checks, system prompts, heartbeats)
|
|
464
|
+
const SKIP_PATTERNS = [
|
|
465
|
+
/^You are running a boot check/i,
|
|
466
|
+
/^Read HEARTBEAT\.md/i,
|
|
467
|
+
/^System:/,
|
|
468
|
+
/^\[System Message\]/,
|
|
469
|
+
/^HEARTBEAT_OK$/,
|
|
470
|
+
/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s/, // Timestamped system events
|
|
471
|
+
/^A subagent task/i,
|
|
472
|
+
/subagent.*completed/i,
|
|
473
|
+
];
|
|
474
|
+
function isInternalContent(text) {
|
|
475
|
+
return SKIP_PATTERNS.some(p => p.test(text.trim()));
|
|
476
|
+
}
|
|
477
|
+
function handleLlmInput(event, ctx) {
|
|
478
|
+
// Fire and forget
|
|
479
|
+
(async () => {
|
|
480
|
+
try {
|
|
481
|
+
// Only scan user content, skip system/boot/heartbeat prompts
|
|
482
|
+
const userTexts = extractUserContent(event.historyMessages).slice(-5);
|
|
483
|
+
const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t));
|
|
484
|
+
for (const text of texts) {
|
|
485
|
+
if (!text || text.length < 10)
|
|
486
|
+
continue;
|
|
487
|
+
const result = await scanRealtimeContent(text);
|
|
488
|
+
if (!result.clean) {
|
|
489
|
+
console.warn(`[shieldcortex] ⚠️ Threat in LLM input: ${result.summary}`);
|
|
490
|
+
const entry = {
|
|
491
|
+
type: "threat", hook: "llm_input", sessionId: event.sessionId,
|
|
492
|
+
model: event.model, reason: result.summary,
|
|
493
|
+
preview: text.slice(0, 100), ts: new Date().toISOString(),
|
|
494
|
+
};
|
|
495
|
+
auditLog(entry);
|
|
496
|
+
loadConfig()
|
|
497
|
+
.then(cfg => cloudSync({ ...entry, content: text.slice(0, 200) }, cfg))
|
|
498
|
+
.catch(() => { });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch (e) {
|
|
503
|
+
console.error("[shieldcortex] llm_input error:", e instanceof Error ? e.message : String(e));
|
|
504
|
+
}
|
|
505
|
+
})();
|
|
506
|
+
}
|
|
507
|
+
// Skip text blocks that are ShieldCortex/OpenClaw tool-result pass-throughs
|
|
508
|
+
function isToolResultContent(text) {
|
|
509
|
+
// ShieldCortex recall returns "Found N memories:" header
|
|
510
|
+
if (/^Found \d+ memor(?:y|ies):/m.test(text))
|
|
511
|
+
return true;
|
|
512
|
+
// ShieldCortex get_context returns structured context blocks
|
|
513
|
+
if (/^## (?:Architecture|Patterns|Preferences|Errors|Context)/m.test(text))
|
|
514
|
+
return true;
|
|
515
|
+
// OpenClaw tool-result wrapper markers
|
|
516
|
+
if (/^\[tool_result\b/i.test(text.trim()))
|
|
517
|
+
return true;
|
|
518
|
+
if (/^<tool_result\b/i.test(text.trim()))
|
|
519
|
+
return true;
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
function handleLlmOutput(event, ctx) {
|
|
523
|
+
// Fire and forget
|
|
524
|
+
(async () => {
|
|
525
|
+
try {
|
|
526
|
+
const config = await loadConfig();
|
|
527
|
+
if (!isAutoMemoryEnabled(config))
|
|
528
|
+
return;
|
|
529
|
+
const texts = event.assistantTexts
|
|
530
|
+
.filter(t => t && t.length >= 30)
|
|
531
|
+
.filter(t => !isToolResultContent(t));
|
|
532
|
+
if (!texts.length)
|
|
533
|
+
return;
|
|
534
|
+
const memories = extractMemories(texts);
|
|
535
|
+
if (!memories.length)
|
|
536
|
+
return;
|
|
537
|
+
const noveltyGate = await createNoveltyGate(config);
|
|
538
|
+
let saved = 0;
|
|
539
|
+
let skipped = 0;
|
|
540
|
+
for (const mem of memories) {
|
|
541
|
+
const novelty = noveltyGate.inspect(mem.content);
|
|
542
|
+
if (!novelty.allow) {
|
|
543
|
+
skipped++;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const r = await callCortex("remember", {
|
|
547
|
+
title: mem.title, content: mem.content, category: mem.category,
|
|
548
|
+
project: ctx.agentId || "openclaw", scope: "global",
|
|
549
|
+
importance: "normal", tags: "auto-extracted,realtime-plugin,llm-output",
|
|
550
|
+
sourceType: "agent", sourceIdentifier: `openclaw-plugin:${event.sessionId}`,
|
|
551
|
+
sessionId: event.sessionId, agentId: ctx.agentId || "openclaw", workspaceDir: ctx.workspaceDir || "",
|
|
552
|
+
});
|
|
553
|
+
if (r) {
|
|
554
|
+
saved++;
|
|
555
|
+
noveltyGate.remember(mem, novelty);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
await noveltyGate.flush();
|
|
559
|
+
if (saved) {
|
|
560
|
+
console.log(`[shieldcortex] Extracted ${saved} memor${saved === 1 ? "y" : "ies"} from LLM output (${skipped} duplicates skipped)`);
|
|
561
|
+
auditLog({ type: "memory", hook: "llm_output", sessionId: event.sessionId, count: saved, skipped, ts: new Date().toISOString() });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
catch (e) {
|
|
565
|
+
console.error("[shieldcortex] llm_output error:", e instanceof Error ? e.message : String(e));
|
|
566
|
+
}
|
|
567
|
+
})();
|
|
568
|
+
}
|
|
569
|
+
// ==================== PLUGIN EXPORT ====================
|
|
570
|
+
export default {
|
|
571
|
+
id: PLUGIN_ID,
|
|
572
|
+
name: "ShieldCortex Real-time Scanner",
|
|
573
|
+
description: "Real-time defence scanning on LLM inputs with optional memory extraction from outputs",
|
|
574
|
+
version: _version,
|
|
575
|
+
configSchema: {
|
|
576
|
+
parse(value) {
|
|
577
|
+
return normaliseConfig(value);
|
|
578
|
+
},
|
|
579
|
+
uiHints: PLUGIN_CONFIG_UI_HINTS,
|
|
580
|
+
jsonSchema: PLUGIN_CONFIG_JSON_SCHEMA,
|
|
581
|
+
},
|
|
582
|
+
register(api) {
|
|
583
|
+
if (_registered)
|
|
584
|
+
return;
|
|
585
|
+
_registered = true;
|
|
586
|
+
try {
|
|
587
|
+
applyPluginConfigOverride(api);
|
|
588
|
+
// --- Interceptor (lazy init) ---
|
|
589
|
+
let interceptorReady = null;
|
|
590
|
+
let interceptorInitAttempted = false;
|
|
591
|
+
async function initInterceptor() {
|
|
592
|
+
if (interceptorInitAttempted)
|
|
593
|
+
return interceptorReady;
|
|
594
|
+
interceptorInitAttempted = true;
|
|
595
|
+
try {
|
|
596
|
+
const scConfig = await loadConfig();
|
|
597
|
+
const rawInterceptorConfig = scConfig.interceptor;
|
|
598
|
+
const interceptorConfig = {
|
|
599
|
+
...DEFAULT_INTERCEPTOR_CONFIG,
|
|
600
|
+
...(rawInterceptorConfig && typeof rawInterceptorConfig === 'object' ? {
|
|
601
|
+
enabled: rawInterceptorConfig.enabled ?? DEFAULT_INTERCEPTOR_CONFIG.enabled,
|
|
602
|
+
severityActions: { ...DEFAULT_INTERCEPTOR_CONFIG.severityActions, ...rawInterceptorConfig.severityActions },
|
|
603
|
+
failurePolicy: { ...DEFAULT_INTERCEPTOR_CONFIG.failurePolicy, ...rawInterceptorConfig.failurePolicy },
|
|
604
|
+
} : {}),
|
|
605
|
+
logger: { info: api.logger?.info ?? console.log, warn: api.logger?.warn ?? console.warn },
|
|
606
|
+
};
|
|
607
|
+
if (!interceptorConfig.enabled)
|
|
608
|
+
return null;
|
|
609
|
+
// Dynamic import with string variable to prevent TypeScript from resolving
|
|
610
|
+
// at compile time — 'shieldcortex/defence' only exists at runtime when the
|
|
611
|
+
// package is installed globally, not during CI builds of the plugin itself.
|
|
612
|
+
let defenceMod;
|
|
613
|
+
try {
|
|
614
|
+
const defenceModPath = 'shieldcortex' + '/defence';
|
|
615
|
+
defenceMod = await import(/* webpackIgnore: true */ defenceModPath);
|
|
616
|
+
}
|
|
617
|
+
catch (importErr) {
|
|
618
|
+
// Stack overflow or missing module — interceptor can't load
|
|
619
|
+
api.logger?.warn?.(`[shieldcortex] Cannot load defence module: ${importErr instanceof Error ? importErr.message : importErr}`);
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
if (typeof defenceMod.runDefencePipeline !== 'function')
|
|
623
|
+
return null;
|
|
624
|
+
interceptorReady = createInterceptor(interceptorConfig, defenceMod.runDefencePipeline, {
|
|
625
|
+
onAuditEntry: (entry) => syncInterceptEvent(entry, {
|
|
626
|
+
cloudApiKey: scConfig.cloudApiKey ?? '',
|
|
627
|
+
cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
|
|
628
|
+
cloudEnabled: scConfig.cloudEnabled ?? false,
|
|
629
|
+
}),
|
|
630
|
+
});
|
|
631
|
+
api.logger?.info?.('[shieldcortex] Interceptor active — watching: remember, mcp__memory__remember');
|
|
632
|
+
return interceptorReady;
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
api.logger?.warn?.(`[shieldcortex] Interceptor init failed: ${err instanceof Error ? err.message : err}`);
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// Register before_tool_call with lazy-init wrapper
|
|
640
|
+
api.registerHook('before_tool_call', async (context) => {
|
|
641
|
+
const interceptor = await initInterceptor();
|
|
642
|
+
if (!interceptor)
|
|
643
|
+
return;
|
|
644
|
+
try {
|
|
645
|
+
await interceptor.handleToolCall(context);
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
// Intentional blocks from the interceptor (ShieldCortex: ...) should propagate
|
|
649
|
+
if (err instanceof Error && err.message.startsWith('ShieldCortex:'))
|
|
650
|
+
throw err;
|
|
651
|
+
// Unexpected errors (DB crash, etc.) — log and allow the tool call through
|
|
652
|
+
api.logger?.warn?.(`[shieldcortex] Interceptor error (allowing tool call): ${err instanceof Error ? err.message : err}`);
|
|
653
|
+
}
|
|
654
|
+
}, {
|
|
655
|
+
name: 'shieldcortex-intercept-tool',
|
|
656
|
+
description: 'Active threat gating on tool calls',
|
|
657
|
+
});
|
|
658
|
+
// Try to register session_end for cache cleanup
|
|
659
|
+
try {
|
|
660
|
+
api.registerHook('session_end', () => { interceptorReady?.resetSession(); }, {
|
|
661
|
+
name: 'shieldcortex-session-cleanup',
|
|
662
|
+
description: 'Clear interceptor deny cache on session end',
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
// session_end may not be a supported hook — TTL safety net handles this
|
|
667
|
+
}
|
|
668
|
+
// Explicit capability registration (replaces legacy api.on)
|
|
669
|
+
api.registerHook("llm_input", handleLlmInput, {
|
|
670
|
+
name: "shieldcortex-scan-input",
|
|
671
|
+
description: "Real-time threat scanning on LLM input",
|
|
672
|
+
});
|
|
673
|
+
api.registerHook("llm_output", handleLlmOutput, {
|
|
674
|
+
name: "shieldcortex-scan-output",
|
|
675
|
+
description: "Memory extraction from LLM output",
|
|
676
|
+
});
|
|
677
|
+
// Register a lightweight status command so the plugin is not hook-only
|
|
678
|
+
api.registerCommand({
|
|
679
|
+
name: "shieldcortex-status",
|
|
680
|
+
description: "Show ShieldCortex real-time scanner status",
|
|
681
|
+
async handler() {
|
|
682
|
+
const cfg = await loadConfig();
|
|
683
|
+
const autoMemory = isAutoMemoryEnabled(cfg) ? "on" : "off";
|
|
684
|
+
const dedupe = isAutoMemoryDedupeEnabled(cfg) ? "on" : "off";
|
|
685
|
+
const cloud = cfg.cloudApiKey ? "configured" : "not configured";
|
|
686
|
+
return {
|
|
687
|
+
text: `ShieldCortex v${_version}\n` +
|
|
688
|
+
` Hooks: llm_input (scan), llm_output (memory)\n` +
|
|
689
|
+
` Auto memory: ${autoMemory} | Dedupe: ${dedupe}\n` +
|
|
690
|
+
` Cloud sync: ${cloud}`,
|
|
691
|
+
};
|
|
692
|
+
},
|
|
693
|
+
});
|
|
694
|
+
api.logger.info(`[shieldcortex] v${_version} registered (llm_input + llm_output + before_tool_call + /shieldcortex-status)`);
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
// Plugin must never block channel startup — warn and bail gracefully
|
|
698
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
699
|
+
console.warn(`[shieldcortex] WARNING: Plugin failed to initialize: ${msg}`);
|
|
700
|
+
console.warn('[shieldcortex] Real-time scanning is disabled. Channels will start normally.');
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function syncInterceptEvent(event, config) {
|
|
2
|
+
if (!config.cloudEnabled || !config.cloudApiKey)
|
|
3
|
+
return;
|
|
4
|
+
const url = `${config.cloudBaseUrl}/v1/audit/ingest`;
|
|
5
|
+
fetch(url, {
|
|
6
|
+
method: 'POST',
|
|
7
|
+
headers: {
|
|
8
|
+
'Content-Type': 'application/json',
|
|
9
|
+
Authorization: `Bearer ${config.cloudApiKey}`,
|
|
10
|
+
},
|
|
11
|
+
body: JSON.stringify({
|
|
12
|
+
events: [{ ...event, source: 'openclaw-interceptor' }],
|
|
13
|
+
}),
|
|
14
|
+
signal: AbortSignal.timeout(5_000),
|
|
15
|
+
}).catch(() => {
|
|
16
|
+
// Fire-and-forget — never block on cloud sync failure
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
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
|
+
const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'];
|
|
6
|
+
const CONTENT_FIELDS = {
|
|
7
|
+
remember: ['content', 'title'],
|
|
8
|
+
mcp__memory__remember: ['content', 'title'],
|
|
9
|
+
};
|
|
10
|
+
// Defaults relaxed in v4.11.0: critical/high no longer block the tool call with
|
|
11
|
+
// a synchronous approval prompt. The defence pipeline still runs and
|
|
12
|
+
// `failurePolicy` still denies on critical/high, so the block is preserved —
|
|
13
|
+
// what changes is the user-facing approval gate. Opt back in with
|
|
14
|
+
// `severityActions: { high: 'require_approval', critical: 'require_approval' }`.
|
|
15
|
+
const DEFAULT_CONFIG = {
|
|
16
|
+
enabled: true,
|
|
17
|
+
severityActions: {
|
|
18
|
+
low: 'log',
|
|
19
|
+
medium: 'log',
|
|
20
|
+
high: 'warn',
|
|
21
|
+
critical: 'log',
|
|
22
|
+
},
|
|
23
|
+
failurePolicy: {
|
|
24
|
+
low: 'allow',
|
|
25
|
+
medium: 'allow',
|
|
26
|
+
high: 'deny',
|
|
27
|
+
critical: 'deny',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
export { WATCHED_TOOLS, CONTENT_FIELDS, DEFAULT_CONFIG };
|
|
31
|
+
export function extractContent(toolName, args) {
|
|
32
|
+
const fields = CONTENT_FIELDS[toolName];
|
|
33
|
+
if (!fields)
|
|
34
|
+
return { title: '', content: '' };
|
|
35
|
+
const title = typeof args.title === 'string' ? args.title : '';
|
|
36
|
+
const content = typeof args.content === 'string' ? args.content : '';
|
|
37
|
+
return { title, content };
|
|
38
|
+
}
|
|
39
|
+
export function mapSeverity(firewall) {
|
|
40
|
+
if (firewall.result === 'BLOCK')
|
|
41
|
+
return 'critical';
|
|
42
|
+
if (firewall.result === 'QUARANTINE')
|
|
43
|
+
return 'high';
|
|
44
|
+
if (firewall.result === 'ALLOW' && firewall.anomalyScore >= 0.3)
|
|
45
|
+
return 'medium';
|
|
46
|
+
return 'low';
|
|
47
|
+
}
|
|
48
|
+
// --- Deny Cache ---
|
|
49
|
+
// Exact replica of normalizeMemoryText() from index.ts (lines 426-434).
|
|
50
|
+
// Must produce identical output for SHA-256 hash consistency.
|
|
51
|
+
function normaliseContent(text) {
|
|
52
|
+
return String(text || '')
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/[`"'\\]/g, ' ')
|
|
55
|
+
.replace(/https?:\/\/\S+/g, ' ')
|
|
56
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
57
|
+
.replace(/\s+/g, ' ')
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
function hashContent(text) {
|
|
61
|
+
return createHash('sha256').update(normaliseContent(text)).digest('hex');
|
|
62
|
+
}
|
|
63
|
+
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
|
|
64
|
+
export class DenyCache {
|
|
65
|
+
cache = new Map();
|
|
66
|
+
maxPerTool;
|
|
67
|
+
ttlMs;
|
|
68
|
+
constructor(maxPerTool = 200, ttlMs = TWO_HOURS_MS) {
|
|
69
|
+
this.maxPerTool = maxPerTool;
|
|
70
|
+
this.ttlMs = ttlMs;
|
|
71
|
+
}
|
|
72
|
+
isDenied(tool, content) {
|
|
73
|
+
const entries = this.cache.get(tool);
|
|
74
|
+
if (!entries)
|
|
75
|
+
return false;
|
|
76
|
+
const hash = hashContent(content);
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
return entries.some(e => e.hash === hash && (now - e.ts) < this.ttlMs);
|
|
79
|
+
}
|
|
80
|
+
addDenial(tool, content) {
|
|
81
|
+
const hash = hashContent(content);
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
if (!this.cache.has(tool)) {
|
|
84
|
+
this.cache.set(tool, []);
|
|
85
|
+
}
|
|
86
|
+
const entries = this.cache.get(tool);
|
|
87
|
+
const live = entries.filter(e => (now - e.ts) < this.ttlMs);
|
|
88
|
+
if (live.some(e => e.hash === hash))
|
|
89
|
+
return;
|
|
90
|
+
live.push({ hash, ts: now });
|
|
91
|
+
while (live.length > this.maxPerTool) {
|
|
92
|
+
live.shift();
|
|
93
|
+
}
|
|
94
|
+
this.cache.set(tool, live);
|
|
95
|
+
}
|
|
96
|
+
reset() {
|
|
97
|
+
this.cache.clear();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// --- Rate Limiter ---
|
|
101
|
+
export class RateLimiter {
|
|
102
|
+
timestamps = [];
|
|
103
|
+
maxPerWindow;
|
|
104
|
+
windowMs;
|
|
105
|
+
constructor(maxPerWindow = 5, windowMs = 60_000) {
|
|
106
|
+
this.maxPerWindow = maxPerWindow;
|
|
107
|
+
this.windowMs = windowMs;
|
|
108
|
+
}
|
|
109
|
+
shouldAllow() {
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
this.timestamps = this.timestamps.filter(t => now - t < this.windowMs);
|
|
112
|
+
if (this.timestamps.length >= this.maxPerWindow)
|
|
113
|
+
return false;
|
|
114
|
+
this.timestamps.push(now);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function formatApprovalPrompt(input) {
|
|
119
|
+
const preview = input.content.length > 200
|
|
120
|
+
? input.content.slice(0, 200) + '...'
|
|
121
|
+
: input.content;
|
|
122
|
+
const threatList = input.threats.length > 0
|
|
123
|
+
? input.threats.join(', ')
|
|
124
|
+
: 'none identified';
|
|
125
|
+
return [
|
|
126
|
+
'🛡️ ShieldCortex — Tool Call Intercepted',
|
|
127
|
+
'',
|
|
128
|
+
`Tool: ${input.tool}`,
|
|
129
|
+
`Risk: ${input.severity} (${input.firewallResult})`,
|
|
130
|
+
`Threats: ${threatList}`,
|
|
131
|
+
`Content: "${preview}"`,
|
|
132
|
+
'',
|
|
133
|
+
'[Approve] [Deny]',
|
|
134
|
+
].join('\n');
|
|
135
|
+
}
|
|
136
|
+
// --- Audit Logging (local JSONL) ---
|
|
137
|
+
const AUDIT_DIR = join(homedir(), '.shieldcortex', 'audit');
|
|
138
|
+
function writeAuditEntry(entry) {
|
|
139
|
+
try {
|
|
140
|
+
mkdirSync(AUDIT_DIR, { recursive: true });
|
|
141
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
142
|
+
const file = join(AUDIT_DIR, `realtime-${date}.jsonl`);
|
|
143
|
+
appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Best-effort — never block on audit failure
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// --- X-Ray Inline Guard ---
|
|
150
|
+
// Lightweight inline version of xrayMemoryContent for the plugin build boundary.
|
|
151
|
+
// Detects AI directive injection patterns in memory content.
|
|
152
|
+
const XRAY_AI_DIRECTIVE_PATTERNS = [
|
|
153
|
+
/ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|prompts?|context)/i,
|
|
154
|
+
/disregard\s+(all\s+)?(previous|prior|above)\s+(instructions?|rules?)/i,
|
|
155
|
+
/override\s+(previous|prior|all)\s+(instructions?|rules?|constraints?)/i,
|
|
156
|
+
/you\s+are\s+now\s+(?:in\s+)?(?:developer|god|admin|root|unrestricted)\s+mode/i,
|
|
157
|
+
/enter\s+(?:developer|god|admin|DAN|jailbreak)\s+mode/i,
|
|
158
|
+
/(?:system|hidden|secret)\s*(?:prompt|instruction|directive)\s*:/i,
|
|
159
|
+
/\[SYSTEM\]\s*:/i,
|
|
160
|
+
/\[INST\]/i,
|
|
161
|
+
/<\|(?:system|user|assistant|im_start|im_end)\|>/i,
|
|
162
|
+
/(?:decode|execute|follow)\s+(?:the\s+)?hidden\s+(?:instructions?|payload|message)/i,
|
|
163
|
+
/(?:hidden|embedded|encoded)\s+(?:instructions?|directive|command)\s+(?:in|within|inside)/i,
|
|
164
|
+
];
|
|
165
|
+
const XRAY_FILENAME_PATTERNS = [
|
|
166
|
+
/ignore_previous/i, /decode_hidden/i, /execute_instructions/i,
|
|
167
|
+
/override_previous/i, /developer_mode/i, /system_prompt/i,
|
|
168
|
+
/jailbreak/i, /\[SYSTEM\]/i, /\[INST\]/i,
|
|
169
|
+
];
|
|
170
|
+
function xrayMemoryGuard(content, title) {
|
|
171
|
+
const findings = [];
|
|
172
|
+
const text = content.length > 50000 ? content.slice(0, 50000) : content;
|
|
173
|
+
for (const pattern of XRAY_AI_DIRECTIVE_PATTERNS) {
|
|
174
|
+
if (pattern.test(text)) {
|
|
175
|
+
findings.push({ category: 'ai-directive', title: 'AI directive injection detected', severity: 'critical' });
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (title) {
|
|
180
|
+
for (const pattern of XRAY_FILENAME_PATTERNS) {
|
|
181
|
+
if (pattern.test(title)) {
|
|
182
|
+
findings.push({ category: 'ai-directive', title: 'AI directive in title', severity: 'critical' });
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Score: 100 - 60 per critical finding (single critical = blocked)
|
|
188
|
+
const score = Math.max(0, 100 - findings.length * 60);
|
|
189
|
+
const riskLevel = score >= 80 ? 'SAFE' : score >= 60 ? 'LOW' : score >= 40 ? 'MEDIUM' : score >= 20 ? 'HIGH' : 'CRITICAL';
|
|
190
|
+
return { allowed: score >= 60, findings, riskLevel };
|
|
191
|
+
}
|
|
192
|
+
export function createInterceptor(config, pipeline, options) {
|
|
193
|
+
const denyCache = new DenyCache();
|
|
194
|
+
const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
|
|
195
|
+
const log = config.logger ?? { info: console.log, warn: console.warn };
|
|
196
|
+
const onAuditEntry = options?.onAuditEntry;
|
|
197
|
+
function emitAudit(entry) {
|
|
198
|
+
writeAuditEntry(entry);
|
|
199
|
+
onAuditEntry?.(entry);
|
|
200
|
+
}
|
|
201
|
+
async function handleToolCall(context) {
|
|
202
|
+
if (!WATCHED_TOOLS.includes(context.toolName))
|
|
203
|
+
return;
|
|
204
|
+
const { title, content } = extractContent(context.toolName, context.arguments);
|
|
205
|
+
const fullContent = [title, content].filter(Boolean).join(' ');
|
|
206
|
+
if (!fullContent.trim())
|
|
207
|
+
return;
|
|
208
|
+
// X-Ray content scan — fast, synchronous, no I/O
|
|
209
|
+
const xrayResult = xrayMemoryGuard(content, title || undefined);
|
|
210
|
+
if (!xrayResult.allowed) {
|
|
211
|
+
const xrayEntry = {
|
|
212
|
+
type: 'intercept', tool: context.toolName, severity: 'critical',
|
|
213
|
+
firewallResult: 'BLOCK', threats: xrayResult.findings.map(f => f.category),
|
|
214
|
+
anomalyScore: 1, action: 'auto_deny', outcome: 'auto_denied',
|
|
215
|
+
preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
|
|
216
|
+
};
|
|
217
|
+
emitAudit(xrayEntry);
|
|
218
|
+
throw new Error(`ShieldCortex: tool call blocked by X-Ray memory guard (risk: ${xrayResult.riskLevel}, findings: ${xrayResult.findings.length})`);
|
|
219
|
+
}
|
|
220
|
+
let severity;
|
|
221
|
+
let firewallResult;
|
|
222
|
+
let threats;
|
|
223
|
+
let anomalyScore;
|
|
224
|
+
try {
|
|
225
|
+
const result = pipeline(content, title, { type: 'agent', identifier: 'openclaw' });
|
|
226
|
+
severity = mapSeverity(result.firewall);
|
|
227
|
+
firewallResult = result.firewall.result;
|
|
228
|
+
threats = result.firewall.threatIndicators;
|
|
229
|
+
anomalyScore = result.firewall.anomalyScore;
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
log.warn(`[shieldcortex] ⚠️ Defence pipeline error: ${err instanceof Error ? err.message : err}`);
|
|
233
|
+
const failAction = config.failurePolicy.high;
|
|
234
|
+
const entry = {
|
|
235
|
+
type: 'intercept', tool: context.toolName, severity: 'high',
|
|
236
|
+
firewallResult: 'ERROR', threats: ['pipeline_error'], anomalyScore: 0,
|
|
237
|
+
action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed',
|
|
238
|
+
preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
|
|
239
|
+
};
|
|
240
|
+
emitAudit(entry);
|
|
241
|
+
if (failAction === 'deny') {
|
|
242
|
+
throw new Error('ShieldCortex: tool call blocked — pipeline error, failure policy: deny');
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (denyCache.isDenied(context.toolName, fullContent)) {
|
|
247
|
+
const entry = {
|
|
248
|
+
type: 'intercept', tool: context.toolName, severity, firewallResult,
|
|
249
|
+
threats, anomalyScore, action: 'auto_deny', outcome: 'auto_denied',
|
|
250
|
+
preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
|
|
251
|
+
};
|
|
252
|
+
emitAudit(entry);
|
|
253
|
+
throw new Error('ShieldCortex: tool call auto-denied (previously denied content)');
|
|
254
|
+
}
|
|
255
|
+
const action = config.severityActions[severity];
|
|
256
|
+
if (action === 'log') {
|
|
257
|
+
const entry = {
|
|
258
|
+
type: 'intercept', tool: context.toolName, severity, firewallResult,
|
|
259
|
+
threats, anomalyScore, action: 'log', outcome: 'logged',
|
|
260
|
+
preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
|
|
261
|
+
};
|
|
262
|
+
emitAudit(entry);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (action === 'warn') {
|
|
266
|
+
log.warn(`[shieldcortex] ⚠️ ${severity} risk in ${context.toolName}: ${threats.join(', ') || 'anomaly detected'}`);
|
|
267
|
+
const entry = {
|
|
268
|
+
type: 'intercept', tool: context.toolName, severity, firewallResult,
|
|
269
|
+
threats, anomalyScore, action: 'warn', outcome: 'warned',
|
|
270
|
+
preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
|
|
271
|
+
};
|
|
272
|
+
emitAudit(entry);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// action === 'require_approval'
|
|
276
|
+
if (typeof context.requireApproval !== 'function') {
|
|
277
|
+
// requireApproval unavailable (pre-v2026.3.28) — apply failurePolicy, not blanket allow
|
|
278
|
+
const failAction = config.failurePolicy[severity];
|
|
279
|
+
log.warn(`[shieldcortex] ⚠️ requireApproval not available for ${severity} risk in ${context.toolName} — failure policy: ${failAction}`);
|
|
280
|
+
const entry = {
|
|
281
|
+
type: 'intercept', tool: context.toolName, severity, firewallResult,
|
|
282
|
+
threats, anomalyScore, action: 'require_approval',
|
|
283
|
+
outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed',
|
|
284
|
+
preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
|
|
285
|
+
};
|
|
286
|
+
emitAudit(entry);
|
|
287
|
+
if (failAction === 'deny') {
|
|
288
|
+
throw new Error(`ShieldCortex: tool call blocked — requireApproval unavailable, failure policy: deny`);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!rateLimiter.shouldAllow()) {
|
|
293
|
+
log.warn('[shieldcortex] ⚠️ Too many approval prompts — auto-denying');
|
|
294
|
+
const entry = {
|
|
295
|
+
type: 'intercept', tool: context.toolName, severity, firewallResult,
|
|
296
|
+
threats, anomalyScore, action: 'rate_limit', outcome: 'auto_denied',
|
|
297
|
+
preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
|
|
298
|
+
};
|
|
299
|
+
emitAudit(entry);
|
|
300
|
+
denyCache.addDenial(context.toolName, fullContent);
|
|
301
|
+
throw new Error('ShieldCortex: tool call auto-denied (rate limit exceeded)');
|
|
302
|
+
}
|
|
303
|
+
const message = formatApprovalPrompt({ tool: context.toolName, severity, firewallResult, threats, content: fullContent });
|
|
304
|
+
let approved;
|
|
305
|
+
try {
|
|
306
|
+
approved = await context.requireApproval(message);
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
const failAction = config.failurePolicy[severity];
|
|
310
|
+
log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
|
|
311
|
+
const entry = {
|
|
312
|
+
type: 'intercept', tool: context.toolName, severity, firewallResult,
|
|
313
|
+
threats, anomalyScore, action: 'require_approval',
|
|
314
|
+
outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed',
|
|
315
|
+
preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
|
|
316
|
+
};
|
|
317
|
+
emitAudit(entry);
|
|
318
|
+
if (failAction === 'deny') {
|
|
319
|
+
throw new Error(`ShieldCortex: tool call blocked — requireApproval error, failure policy: deny`);
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (approved) {
|
|
324
|
+
const entry = {
|
|
325
|
+
type: 'intercept', tool: context.toolName, severity, firewallResult,
|
|
326
|
+
threats, anomalyScore, action: 'require_approval', outcome: 'approved',
|
|
327
|
+
preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
|
|
328
|
+
};
|
|
329
|
+
emitAudit(entry);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Denied
|
|
333
|
+
denyCache.addDenial(context.toolName, fullContent);
|
|
334
|
+
const entry = {
|
|
335
|
+
type: 'intercept', tool: context.toolName, severity, firewallResult,
|
|
336
|
+
threats, anomalyScore, action: 'require_approval', outcome: 'denied',
|
|
337
|
+
preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
|
|
338
|
+
};
|
|
339
|
+
emitAudit(entry);
|
|
340
|
+
throw new Error('ShieldCortex: tool call denied by user');
|
|
341
|
+
}
|
|
342
|
+
function resetSession() {
|
|
343
|
+
denyCache.reset();
|
|
344
|
+
}
|
|
345
|
+
return { handleToolCall, resetSession };
|
|
346
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "shieldcortex-realtime",
|
|
3
|
+
"version": "4.14.0",
|
|
4
|
+
"name": "ShieldCortex Real-time Scanner",
|
|
5
|
+
"description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
|
|
6
|
+
"kind": null,
|
|
7
|
+
"engines": {
|
|
8
|
+
"openclaw": ">=2026.3.22",
|
|
9
|
+
"recommended": ">=2026.4.23"
|
|
10
|
+
},
|
|
11
|
+
"enabledByDefault": false,
|
|
12
|
+
"activation": {
|
|
13
|
+
"onStartup": false,
|
|
14
|
+
"hooks": ["llm_input", "llm_output", "before_tool_call", "session_end"],
|
|
15
|
+
"commands": ["shieldcortex-status"]
|
|
16
|
+
},
|
|
17
|
+
"contracts": {},
|
|
18
|
+
"commandAliases": {
|
|
19
|
+
"shieldcortex-status": "shieldcortex-status"
|
|
20
|
+
},
|
|
21
|
+
"uiHints": {
|
|
22
|
+
"binaryPath": {
|
|
23
|
+
"label": "ShieldCortex Binary Path",
|
|
24
|
+
"help": "Optional absolute path to the shieldcortex CLI when it is not on PATH.",
|
|
25
|
+
"placeholder": "/usr/local/bin/shieldcortex",
|
|
26
|
+
"advanced": true
|
|
27
|
+
},
|
|
28
|
+
"cloudApiKey": {
|
|
29
|
+
"label": "Cloud API Key",
|
|
30
|
+
"help": "Optional ShieldCortex Cloud API key used for realtime threat forwarding.",
|
|
31
|
+
"placeholder": "sc_...",
|
|
32
|
+
"sensitive": true
|
|
33
|
+
},
|
|
34
|
+
"cloudBaseUrl": {
|
|
35
|
+
"label": "Cloud Base URL",
|
|
36
|
+
"help": "Override the ShieldCortex Cloud API base URL if you use a self-hosted or staging endpoint.",
|
|
37
|
+
"placeholder": "https://api.shieldcortex.ai",
|
|
38
|
+
"advanced": true
|
|
39
|
+
},
|
|
40
|
+
"openclawAutoMemory": {
|
|
41
|
+
"label": "Auto Memory Extraction",
|
|
42
|
+
"help": "Extract high-signal decisions and learnings from LLM output into ShieldCortex memory."
|
|
43
|
+
},
|
|
44
|
+
"openclawAutoMemoryDedupe": {
|
|
45
|
+
"label": "Dedupe Auto Memory",
|
|
46
|
+
"help": "Skip near-duplicate memories before they are written to ShieldCortex.",
|
|
47
|
+
"advanced": true
|
|
48
|
+
},
|
|
49
|
+
"openclawAutoMemoryNoveltyThreshold": {
|
|
50
|
+
"label": "Novelty Threshold",
|
|
51
|
+
"help": "Similarity threshold for duplicate suppression. Higher values keep more memories.",
|
|
52
|
+
"advanced": true
|
|
53
|
+
},
|
|
54
|
+
"openclawAutoMemoryMaxRecent": {
|
|
55
|
+
"label": "Recent Memory Cache Size",
|
|
56
|
+
"help": "How many recent extracted memories to keep in the dedupe cache.",
|
|
57
|
+
"advanced": true
|
|
58
|
+
},
|
|
59
|
+
"interceptor.enabled": {
|
|
60
|
+
"label": "Enable Tool Call Interceptor",
|
|
61
|
+
"description": "Scan memory-write tool calls and gate suspicious content behind user approval",
|
|
62
|
+
"type": "boolean"
|
|
63
|
+
},
|
|
64
|
+
"interceptor.severityActions.high": {
|
|
65
|
+
"label": "High Severity Action",
|
|
66
|
+
"description": "Action for high-severity threats (log, warn, require_approval)",
|
|
67
|
+
"type": "string"
|
|
68
|
+
},
|
|
69
|
+
"interceptor.severityActions.critical": {
|
|
70
|
+
"label": "Critical Severity Action",
|
|
71
|
+
"description": "Action for critical-severity threats (log, warn, require_approval)",
|
|
72
|
+
"type": "string"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"configSchema": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"additionalProperties": false,
|
|
78
|
+
"properties": {
|
|
79
|
+
"enabled": {
|
|
80
|
+
"type": "boolean"
|
|
81
|
+
},
|
|
82
|
+
"binaryPath": {
|
|
83
|
+
"type": "string"
|
|
84
|
+
},
|
|
85
|
+
"cloudApiKey": {
|
|
86
|
+
"type": "string"
|
|
87
|
+
},
|
|
88
|
+
"cloudBaseUrl": {
|
|
89
|
+
"type": "string"
|
|
90
|
+
},
|
|
91
|
+
"openclawAutoMemory": {
|
|
92
|
+
"type": "boolean"
|
|
93
|
+
},
|
|
94
|
+
"openclawAutoMemoryDedupe": {
|
|
95
|
+
"type": "boolean"
|
|
96
|
+
},
|
|
97
|
+
"openclawAutoMemoryNoveltyThreshold": {
|
|
98
|
+
"type": "number",
|
|
99
|
+
"minimum": 0.6,
|
|
100
|
+
"maximum": 0.99
|
|
101
|
+
},
|
|
102
|
+
"openclawAutoMemoryMaxRecent": {
|
|
103
|
+
"type": "integer",
|
|
104
|
+
"minimum": 50,
|
|
105
|
+
"maximum": 1000
|
|
106
|
+
},
|
|
107
|
+
"interceptor": {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"properties": {
|
|
110
|
+
"enabled": { "type": "boolean", "default": true },
|
|
111
|
+
"severityActions": {
|
|
112
|
+
"type": "object",
|
|
113
|
+
"additionalProperties": false,
|
|
114
|
+
"properties": {
|
|
115
|
+
"low": { "type": "string", "enum": ["log", "warn", "require_approval"], "default": "log" },
|
|
116
|
+
"medium": { "type": "string", "enum": ["log", "warn", "require_approval"], "default": "log" },
|
|
117
|
+
"high": { "type": "string", "enum": ["log", "warn", "require_approval"], "default": "warn" },
|
|
118
|
+
"critical": { "type": "string", "enum": ["log", "warn", "require_approval"], "default": "log" }
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"failurePolicy": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"additionalProperties": false,
|
|
124
|
+
"properties": {
|
|
125
|
+
"low": { "type": "string", "enum": ["allow", "deny"], "default": "allow" },
|
|
126
|
+
"medium": { "type": "string", "enum": ["allow", "deny"], "default": "allow" },
|
|
127
|
+
"high": { "type": "string", "enum": ["allow", "deny"], "default": "deny" },
|
|
128
|
+
"critical": { "type": "string", "enum": ["allow", "deny"], "default": "deny" }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/index.ts
CHANGED
|
@@ -210,6 +210,11 @@ const PLUGIN_CONFIG_JSON_SCHEMA = {
|
|
|
210
210
|
};
|
|
211
211
|
|
|
212
212
|
let _config: SCConfig | null = null;
|
|
213
|
+
// Identity of the shield config we last merged from. The runtime's
|
|
214
|
+
// loadShieldConfig() returns the same parsed object until the file's mtime
|
|
215
|
+
// advances; using reference equality lets us re-merge precisely when the
|
|
216
|
+
// underlying config has actually changed (dashboard / CLI write).
|
|
217
|
+
let _lastShieldConfigRef: unknown = null;
|
|
213
218
|
let _configOverride: SCConfig | null = null;
|
|
214
219
|
let _version = "0.0.0";
|
|
215
220
|
try {
|
|
@@ -292,16 +297,17 @@ function applyPluginConfigOverride(api: PluginApi): void {
|
|
|
292
297
|
...(_configOverride ?? {}),
|
|
293
298
|
...pluginConfig,
|
|
294
299
|
};
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
300
|
+
// Override changed — invalidate so loadConfig() re-merges with new override.
|
|
301
|
+
_config = null;
|
|
302
|
+
_lastShieldConfigRef = null;
|
|
298
303
|
}
|
|
299
304
|
|
|
300
305
|
async function loadConfig(): Promise<SCConfig> {
|
|
301
|
-
|
|
302
|
-
|
|
306
|
+
const shieldConfigRaw = await (await getRuntime()).loadShieldConfig();
|
|
307
|
+
if (_config && shieldConfigRaw === _lastShieldConfigRef) return _config;
|
|
308
|
+
_lastShieldConfigRef = shieldConfigRaw;
|
|
303
309
|
_config = {
|
|
304
|
-
...
|
|
310
|
+
...normaliseConfig(shieldConfigRaw),
|
|
305
311
|
...(_configOverride ?? {}),
|
|
306
312
|
};
|
|
307
313
|
return _config;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.14.0",
|
|
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,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/shieldcortex-realtime",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.14.0",
|
|
4
4
|
"description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "index.
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"keywords": [
|
|
9
9
|
"openclaw",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"memory"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
|
+
"dist/",
|
|
17
18
|
"index.ts",
|
|
18
19
|
"interceptor.ts",
|
|
19
20
|
"intercept-ingest.ts",
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
"pack:verify": "npm pack --dry-run"
|
|
26
27
|
},
|
|
27
28
|
"peerDependencies": {
|
|
28
|
-
"shieldcortex": "^4.
|
|
29
|
+
"shieldcortex": "^4.14.0",
|
|
29
30
|
"openclaw": ">=2026.3.22"
|
|
30
31
|
},
|
|
31
32
|
"peerDependenciesMeta": {
|
|
@@ -40,7 +41,13 @@
|
|
|
40
41
|
},
|
|
41
42
|
"openclaw": {
|
|
42
43
|
"extensions": [
|
|
43
|
-
"./index.
|
|
44
|
+
"./dist/index.js"
|
|
45
|
+
],
|
|
46
|
+
"hooks": [
|
|
47
|
+
"llm_input",
|
|
48
|
+
"llm_output",
|
|
49
|
+
"before_tool_call",
|
|
50
|
+
"session_end"
|
|
44
51
|
]
|
|
45
52
|
},
|
|
46
53
|
"repository": {
|