@drakon-systems/shieldcortex-realtime 4.54.4 → 4.54.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
32
32
  import { createRequire } from "node:module";
33
33
  import { readConversationAccess, describeRegisteredHooks } from './conversation-access.js';
34
34
  import { createSessionTaintStore } from './session-taint.js';
35
+ import { isTaintingScanSummary, severityFromScanSummary } from './scan-taint-policy.js';
35
36
  import { classifyConversationOrigin } from './conversation-trust.js';
36
37
  import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
37
38
  import { syncInterceptEvent } from './intercept-ingest.js';
@@ -1529,12 +1530,29 @@ export async function scanRealtimeContent(text) {
1529
1530
  if (defenceMod && typeof defenceMod.scanToolResponse === "function") {
1530
1531
  try {
1531
1532
  const scan = defenceMod.scanToolResponse("openclaw-realtime", text, "advisory");
1532
- // Reproduce the historical summary contract exactly: risk level + detection
1533
- // count only when the injection scan flagged something.
1534
- const risk = scan.injection.clean ? "unknown" : scan.injection.riskLevel;
1535
- const summary = scan.injection.clean
1536
- ? risk
1537
- : `${risk} (${scan.injection.detections.length} detections)`;
1533
+ // #361: never summarise a dirty overall scan as bare "unknown".
1534
+ // Historical path used injection.riskLevel only when injection fired; when
1535
+ // another layer dirtied the scan (encoding/instructions/credentials/etc.)
1536
+ // while injection stayed clean, risk defaulted to "unknown" and the llm_input
1537
+ // path treated that as a positive detection → session taint → AG escalate →
1538
+ // broker unavailable deadlock on benign threads.
1539
+ // Prefer the scanner's own multi-layer summary when dirty; only use the
1540
+ // compact "RISK (N detections)" form for pure injection hits.
1541
+ let summary;
1542
+ if (scan.clean) {
1543
+ summary = 'NONE';
1544
+ }
1545
+ else if (!scan.injection.clean) {
1546
+ const risk = scan.injection.riskLevel || 'UNKNOWN';
1547
+ summary = `${risk} (${scan.injection.detections.length} detections)`;
1548
+ }
1549
+ else if (typeof scan.summary === 'string' && scan.summary.trim()) {
1550
+ // e.g. 'THREAT in "openclaw-realtime" response: encoding: base64 (12ms)'
1551
+ summary = scan.summary;
1552
+ }
1553
+ else {
1554
+ summary = 'THREAT (non-injection layer)';
1555
+ }
1538
1556
  return { clean: scan.clean, summary, available: true };
1539
1557
  }
1540
1558
  catch (err) {
@@ -1853,7 +1871,10 @@ async function createNoveltyGate(config) {
1853
1871
  };
1854
1872
  }
1855
1873
  // ==================== HOOK HANDLERS ====================
1856
- // Skip scanning internal OpenClaw content (boot checks, system prompts, heartbeats)
1874
+ // Skip scanning internal OpenClaw content (boot checks, system prompts, heartbeats).
1875
+ // #353: isolated cron prepends a host envelope (`[cron:<id> name]`) so the
1876
+ // heartbeat ritual is no longer at column 0. Recognition of THAT wrap uses
1877
+ // host session identity, never a spoofable prompt prefix.
1857
1878
  const SKIP_PATTERNS = [
1858
1879
  /^You are running a boot check/i,
1859
1880
  /^Read HEARTBEAT\.md/i,
@@ -1864,8 +1885,59 @@ const SKIP_PATTERNS = [
1864
1885
  /^A subagent task/i,
1865
1886
  /subagent.*completed/i,
1866
1887
  ];
1867
- function isInternalContent(text) {
1868
- return SKIP_PATTERNS.some(p => p.test(text.trim()));
1888
+ /** Host-generated isolated heartbeat / cron session keys. Prompt text is never
1889
+ * a substitute. Tight prefixes only — `direct:heartbeat` is not trusted. */
1890
+ const HOST_HEARTBEAT_SESSION = /^(?:agent:[A-Za-z0-9._-]+:)+(?:main:)?heartbeat(?:$|:run:)/i;
1891
+ const HOST_CRON_SESSION = /^(?:agent:[A-Za-z0-9._-]+:)*cron:[A-Za-z0-9._-]+(?:$|:run:)/i;
1892
+ export function isTrustedAutomationSession(sessionId) {
1893
+ if (typeof sessionId !== 'string')
1894
+ return false;
1895
+ const trimmed = sessionId.trim();
1896
+ if (!trimmed || trimmed.length > 256 || /\s/.test(trimmed))
1897
+ return false;
1898
+ return HOST_HEARTBEAT_SESSION.test(trimmed) || HOST_CRON_SESSION.test(trimmed);
1899
+ }
1900
+ const HOST_CRON_ENVELOPE_LINE = /^\s*\[cron:[^\]]+\]\s*$/;
1901
+ const HOST_CRON_ENVELOPE_PREFIX = /^\s*\[cron:[^\]]+\][ \t]+/;
1902
+ /** Drop at most one leading host cron envelope (own line or same-line prefix).
1903
+ * Later `[cron:…]` tokens stay in the body so a user cannot bury an injection
1904
+ * after a fake wrap. */
1905
+ export function stripOneHostCronEnvelope(text) {
1906
+ const raw = String(text ?? '');
1907
+ const nl = raw.search(/\r?\n/);
1908
+ const first = nl === -1 ? raw : raw.slice(0, nl);
1909
+ if (HOST_CRON_ENVELOPE_LINE.test(first)) {
1910
+ return nl === -1 ? '' : raw.slice(nl).replace(/^\r?\n/, '');
1911
+ }
1912
+ if (HOST_CRON_ENVELOPE_PREFIX.test(first)) {
1913
+ const strippedFirst = first.replace(HOST_CRON_ENVELOPE_PREFIX, '');
1914
+ return nl === -1 ? strippedFirst : strippedFirst + raw.slice(nl);
1915
+ }
1916
+ return raw;
1917
+ }
1918
+ function matchesSkipPatterns(text) {
1919
+ return SKIP_PATTERNS.some((p) => p.test(text.trim()));
1920
+ }
1921
+ export function isInternalContent(text, sessionId) {
1922
+ const body = String(text ?? '');
1923
+ if (matchesSkipPatterns(body))
1924
+ return true;
1925
+ if (!isTrustedAutomationSession(sessionId))
1926
+ return false;
1927
+ const inner = stripOneHostCronEnvelope(body);
1928
+ if (inner === body)
1929
+ return false;
1930
+ return matchesSkipPatterns(inner);
1931
+ }
1932
+ function resolveHookSessionId(event, ctx) {
1933
+ const fromEvent = typeof event?.sessionId === 'string' ? event.sessionId.trim() : '';
1934
+ if (fromEvent)
1935
+ return fromEvent;
1936
+ const fromCtx = typeof ctx?.sessionId === 'string' ? ctx.sessionId.trim() : '';
1937
+ if (fromCtx)
1938
+ return fromCtx;
1939
+ const fromKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey.trim() : '';
1940
+ return fromKey || undefined;
1869
1941
  }
1870
1942
  // Awaitable scan body — extracted so the jest suite can verify behaviour
1871
1943
  // deterministically. handleLlmInput wraps this fire-and-forget so the hook
@@ -1905,8 +1977,9 @@ export async function scanLlmInput(event, _ctx) {
1905
1977
  }
1906
1978
  return trustMemo;
1907
1979
  };
1980
+ const sessionId = resolveHookSessionId(event, _ctx);
1908
1981
  const userTexts = extractUserContent(event.historyMessages).slice(-5);
1909
- const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t));
1982
+ const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t, sessionId));
1910
1983
  for (const text of texts) {
1911
1984
  if (!text || text.length < 10)
1912
1985
  continue;
@@ -1952,8 +2025,15 @@ export async function scanLlmInput(event, _ctx) {
1952
2025
  // logs" is an instruction, and treating it as an attack is the false
1953
2026
  // alarm that gets a control switched off. Everything the agent was
1954
2027
  // handed — including another agent on a closed channel — is data.
1955
- if (trust.mayTaint) {
1956
- sessionTaint.mark(event.sessionId, { reason: `conversation scan: ${result.summary}` });
2028
+ // #361: taint only on a concrete detection summary. Bare "unknown" (and
2029
+ // scanner-unavailable shapes) must not escalate Action Guard — they are
2030
+ // degraded/uncertain states, not positive threats. Real injection keeps
2031
+ // the HIGH/CRITICAL (N detections) form and still taints.
2032
+ if (trust.mayTaint && isTaintingScanSummary(result.summary)) {
2033
+ sessionTaint.mark(event.sessionId, {
2034
+ severity: severityFromScanSummary(result.summary),
2035
+ reason: `conversation scan: ${result.summary}`,
2036
+ });
1957
2037
  }
1958
2038
  // #226: NO `preview`. This row carried the first 100 characters of the
1959
2039
  // prompt — the exact text that tripped an injection detector, i.e.
@@ -1972,7 +2052,8 @@ export async function scanLlmInput(event, _ctx) {
1972
2052
  // Whether this detection tainted the session (threat-graph Phase D:
1973
2053
  // lets the threat graph attribute taint-raising events). Metadata
1974
2054
  // only — no content, same as the fields above.
1975
- tainted: trust.mayTaint,
2055
+ // #361: mirrors the mark gate — unknown/unavailable never count as taint.
2056
+ tainted: trust.mayTaint && isTaintingScanSummary(result.summary),
1976
2057
  // Writer-side attestation (attestation Phase 4): the hook identity
1977
2058
  // above is a hardcoded literal — no conversation content can reach
1978
2059
  // it. RECORD-ONLY at the reader (attribution metadata); the JSONL
@@ -2383,11 +2464,12 @@ export async function handleBeforeAgentRun(event, ctx) {
2383
2464
  if (posture === 'off')
2384
2465
  return gatePass();
2385
2466
  const text = String(event?.prompt ?? '');
2386
- if (!text || text.length < 10 || isInternalContent(text))
2387
- return gatePass();
2388
2467
  // sessionId/model come off the hook CONTEXT (PluginHookAgentContext); the
2389
2468
  // event carries neither. Both are optional there too, so both may be absent.
2390
- const sessionId = ctx?.sessionId ?? ctx?.sessionKey;
2469
+ // Resolve BEFORE the internal-content skip so #353 can see the host key.
2470
+ const sessionId = resolveHookSessionId(undefined, ctx);
2471
+ if (!text || text.length < 10 || isInternalContent(text, sessionId))
2472
+ return gatePass();
2391
2473
  const model = ctx?.modelId;
2392
2474
  // scanRealtimeContent no longer throws on the paths that used to (it
2393
2475
  // reports `available:false` instead), but a defensive catch stays: this
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.54.4",
3
+ "version": "4.54.7",
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,
@@ -0,0 +1,37 @@
1
+ /**
2
+ * #361 — which conversation-scan summaries may raise session taint.
3
+ *
4
+ * Session taint escalates Action Guard. Only concrete detections may do that.
5
+ * Uncertainty ("unknown"), unavailable scanners, and empty noise must not.
6
+ */
7
+ export function isTaintingScanSummary(summary) {
8
+ if (!summary || typeof summary !== 'string')
9
+ return false;
10
+ const s = summary.trim();
11
+ if (!s)
12
+ return false;
13
+ const lower = s.toLowerCase();
14
+ if (lower === 'unknown' || lower === 'none' || lower === 'clean')
15
+ return false;
16
+ if (lower.includes('scan unavailable') || lower.includes('unscanned'))
17
+ return false;
18
+ if (/\b(critical|high|medium|low)\b/i.test(s))
19
+ return true;
20
+ if (/\bthreat\b/i.test(s))
21
+ return true;
22
+ if (/\bdetections?\b/i.test(s) && /\d+/.test(s))
23
+ return true;
24
+ return false;
25
+ }
26
+ export function severityFromScanSummary(summary) {
27
+ const s = summary.toLowerCase();
28
+ if (/\bcritical\b/.test(s))
29
+ return 'critical';
30
+ if (/\bhigh\b/.test(s))
31
+ return 'high';
32
+ if (/\bmedium\b/.test(s))
33
+ return 'medium';
34
+ if (/\blow\b/.test(s))
35
+ return 'low';
36
+ return 'medium';
37
+ }
package/index.ts CHANGED
@@ -34,6 +34,7 @@ import { createRequire } from "node:module";
34
34
 
35
35
  import { readConversationAccess, describeRegisteredHooks } from './conversation-access.js';
36
36
  import { createSessionTaintStore } from './session-taint.js';
37
+ import { isTaintingScanSummary, severityFromScanSummary } from './scan-taint-policy.js';
37
38
  import { classifyConversationOrigin } from './conversation-trust.js';
38
39
  import type { ConversationTrustDecision } from './conversation-trust.js';
39
40
  import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
@@ -64,6 +65,8 @@ type DefenceModule = {
64
65
  mode?: 'advisory' | 'enforce',
65
66
  ) => {
66
67
  clean: boolean;
68
+ /** Multi-layer human summary — required for #361 non-injection dirty path. */
69
+ summary?: string;
67
70
  injection: { clean: boolean; riskLevel: string; detections: unknown[] };
68
71
  };
69
72
  /** #225 sink: the notify transport shared with the Action Guard (#143).
@@ -1895,12 +1898,26 @@ export async function scanRealtimeContent(text: string): Promise<ConversationSca
1895
1898
  if (defenceMod && typeof defenceMod.scanToolResponse === "function") {
1896
1899
  try {
1897
1900
  const scan = defenceMod.scanToolResponse("openclaw-realtime", text, "advisory");
1898
- // Reproduce the historical summary contract exactly: risk level + detection
1899
- // count only when the injection scan flagged something.
1900
- const risk = scan.injection.clean ? "unknown" : scan.injection.riskLevel;
1901
- const summary = scan.injection.clean
1902
- ? risk
1903
- : `${risk} (${scan.injection.detections.length} detections)`;
1901
+ // #361: never summarise a dirty overall scan as bare "unknown".
1902
+ // Historical path used injection.riskLevel only when injection fired; when
1903
+ // another layer dirtied the scan (encoding/instructions/credentials/etc.)
1904
+ // while injection stayed clean, risk defaulted to "unknown" and the llm_input
1905
+ // path treated that as a positive detection → session taint → AG escalate →
1906
+ // broker unavailable deadlock on benign threads.
1907
+ // Prefer the scanner's own multi-layer summary when dirty; only use the
1908
+ // compact "RISK (N detections)" form for pure injection hits.
1909
+ let summary: string;
1910
+ if (scan.clean) {
1911
+ summary = 'NONE';
1912
+ } else if (!scan.injection.clean) {
1913
+ const risk = scan.injection.riskLevel || 'UNKNOWN';
1914
+ summary = `${risk} (${scan.injection.detections.length} detections)`;
1915
+ } else if (typeof scan.summary === 'string' && scan.summary.trim()) {
1916
+ // e.g. 'THREAT in "openclaw-realtime" response: encoding: base64 (12ms)'
1917
+ summary = scan.summary;
1918
+ } else {
1919
+ summary = 'THREAT (non-injection layer)';
1920
+ }
1904
1921
  return { clean: scan.clean, summary, available: true };
1905
1922
  } catch (err) {
1906
1923
  // A scanner that THROWS is not a clean verdict either. Same treatment as
@@ -2251,7 +2268,10 @@ async function createNoveltyGate(config: SCConfig): Promise<{
2251
2268
 
2252
2269
  // ==================== HOOK HANDLERS ====================
2253
2270
 
2254
- // Skip scanning internal OpenClaw content (boot checks, system prompts, heartbeats)
2271
+ // Skip scanning internal OpenClaw content (boot checks, system prompts, heartbeats).
2272
+ // #353: isolated cron prepends a host envelope (`[cron:<id> name]`) so the
2273
+ // heartbeat ritual is no longer at column 0. Recognition of THAT wrap uses
2274
+ // host session identity, never a spoofable prompt prefix.
2255
2275
  const SKIP_PATTERNS = [
2256
2276
  /^You are running a boot check/i,
2257
2277
  /^Read HEARTBEAT\.md/i,
@@ -2262,8 +2282,64 @@ const SKIP_PATTERNS = [
2262
2282
  /^A subagent task/i,
2263
2283
  /subagent.*completed/i,
2264
2284
  ];
2265
- function isInternalContent(text: string): boolean {
2266
- return SKIP_PATTERNS.some(p => p.test(text.trim()));
2285
+
2286
+ /** Host-generated isolated heartbeat / cron session keys. Prompt text is never
2287
+ * a substitute. Tight prefixes only — `direct:heartbeat` is not trusted. */
2288
+ const HOST_HEARTBEAT_SESSION =
2289
+ /^(?:agent:[A-Za-z0-9._-]+:)+(?:main:)?heartbeat(?:$|:run:)/i;
2290
+ const HOST_CRON_SESSION =
2291
+ /^(?:agent:[A-Za-z0-9._-]+:)*cron:[A-Za-z0-9._-]+(?:$|:run:)/i;
2292
+
2293
+ export function isTrustedAutomationSession(sessionId?: string | null): boolean {
2294
+ if (typeof sessionId !== 'string') return false;
2295
+ const trimmed = sessionId.trim();
2296
+ if (!trimmed || trimmed.length > 256 || /\s/.test(trimmed)) return false;
2297
+ return HOST_HEARTBEAT_SESSION.test(trimmed) || HOST_CRON_SESSION.test(trimmed);
2298
+ }
2299
+
2300
+ const HOST_CRON_ENVELOPE_LINE = /^\s*\[cron:[^\]]+\]\s*$/;
2301
+ const HOST_CRON_ENVELOPE_PREFIX = /^\s*\[cron:[^\]]+\][ \t]+/;
2302
+
2303
+ /** Drop at most one leading host cron envelope (own line or same-line prefix).
2304
+ * Later `[cron:…]` tokens stay in the body so a user cannot bury an injection
2305
+ * after a fake wrap. */
2306
+ export function stripOneHostCronEnvelope(text: string): string {
2307
+ const raw = String(text ?? '');
2308
+ const nl = raw.search(/\r?\n/);
2309
+ const first = nl === -1 ? raw : raw.slice(0, nl);
2310
+ if (HOST_CRON_ENVELOPE_LINE.test(first)) {
2311
+ return nl === -1 ? '' : raw.slice(nl).replace(/^\r?\n/, '');
2312
+ }
2313
+ if (HOST_CRON_ENVELOPE_PREFIX.test(first)) {
2314
+ const strippedFirst = first.replace(HOST_CRON_ENVELOPE_PREFIX, '');
2315
+ return nl === -1 ? strippedFirst : strippedFirst + raw.slice(nl);
2316
+ }
2317
+ return raw;
2318
+ }
2319
+
2320
+ function matchesSkipPatterns(text: string): boolean {
2321
+ return SKIP_PATTERNS.some((p) => p.test(text.trim()));
2322
+ }
2323
+
2324
+ export function isInternalContent(text: string, sessionId?: string | null): boolean {
2325
+ const body = String(text ?? '');
2326
+ if (matchesSkipPatterns(body)) return true;
2327
+ if (!isTrustedAutomationSession(sessionId)) return false;
2328
+ const inner = stripOneHostCronEnvelope(body);
2329
+ if (inner === body) return false;
2330
+ return matchesSkipPatterns(inner);
2331
+ }
2332
+
2333
+ function resolveHookSessionId(
2334
+ event: { sessionId?: string } | undefined,
2335
+ ctx?: AgentCtx,
2336
+ ): string | undefined {
2337
+ const fromEvent = typeof event?.sessionId === 'string' ? event.sessionId.trim() : '';
2338
+ if (fromEvent) return fromEvent;
2339
+ const fromCtx = typeof ctx?.sessionId === 'string' ? ctx.sessionId.trim() : '';
2340
+ if (fromCtx) return fromCtx;
2341
+ const fromKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey.trim() : '';
2342
+ return fromKey || undefined;
2267
2343
  }
2268
2344
 
2269
2345
  // Awaitable scan body — extracted so the jest suite can verify behaviour
@@ -2304,8 +2380,9 @@ export async function scanLlmInput(event: LlmInputEvent, _ctx: AgentCtx): Promis
2304
2380
  }
2305
2381
  return trustMemo;
2306
2382
  };
2383
+ const sessionId = resolveHookSessionId(event, _ctx);
2307
2384
  const userTexts = extractUserContent(event.historyMessages).slice(-5);
2308
- const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t));
2385
+ const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t, sessionId));
2309
2386
  for (const text of texts) {
2310
2387
  if (!text || text.length < 10) continue;
2311
2388
  const result = await scanRealtimeContent(text);
@@ -2352,8 +2429,15 @@ export async function scanLlmInput(event: LlmInputEvent, _ctx: AgentCtx): Promis
2352
2429
  // logs" is an instruction, and treating it as an attack is the false
2353
2430
  // alarm that gets a control switched off. Everything the agent was
2354
2431
  // handed — including another agent on a closed channel — is data.
2355
- if (trust.mayTaint) {
2356
- sessionTaint.mark(event.sessionId, { reason: `conversation scan: ${result.summary}` });
2432
+ // #361: taint only on a concrete detection summary. Bare "unknown" (and
2433
+ // scanner-unavailable shapes) must not escalate Action Guard — they are
2434
+ // degraded/uncertain states, not positive threats. Real injection keeps
2435
+ // the HIGH/CRITICAL (N detections) form and still taints.
2436
+ if (trust.mayTaint && isTaintingScanSummary(result.summary)) {
2437
+ sessionTaint.mark(event.sessionId, {
2438
+ severity: severityFromScanSummary(result.summary),
2439
+ reason: `conversation scan: ${result.summary}`,
2440
+ });
2357
2441
  }
2358
2442
  // #226: NO `preview`. This row carried the first 100 characters of the
2359
2443
  // prompt — the exact text that tripped an injection detector, i.e.
@@ -2372,7 +2456,8 @@ export async function scanLlmInput(event: LlmInputEvent, _ctx: AgentCtx): Promis
2372
2456
  // Whether this detection tainted the session (threat-graph Phase D:
2373
2457
  // lets the threat graph attribute taint-raising events). Metadata
2374
2458
  // only — no content, same as the fields above.
2375
- tainted: trust.mayTaint,
2459
+ // #361: mirrors the mark gate — unknown/unavailable never count as taint.
2460
+ tainted: trust.mayTaint && isTaintingScanSummary(result.summary),
2376
2461
  // Writer-side attestation (attestation Phase 4): the hook identity
2377
2462
  // above is a hardcoded literal — no conversation content can reach
2378
2463
  // it. RECORD-ONLY at the reader (attribution metadata); the JSONL
@@ -2896,11 +2981,11 @@ export async function handleBeforeAgentRun(
2896
2981
  if (posture === 'off') return gatePass();
2897
2982
 
2898
2983
  const text = String(event?.prompt ?? '');
2899
- if (!text || text.length < 10 || isInternalContent(text)) return gatePass();
2900
-
2901
2984
  // sessionId/model come off the hook CONTEXT (PluginHookAgentContext); the
2902
2985
  // event carries neither. Both are optional there too, so both may be absent.
2903
- const sessionId = ctx?.sessionId ?? ctx?.sessionKey;
2986
+ // Resolve BEFORE the internal-content skip so #353 can see the host key.
2987
+ const sessionId = resolveHookSessionId(undefined, ctx);
2988
+ if (!text || text.length < 10 || isInternalContent(text, sessionId)) return gatePass();
2904
2989
  const model = (ctx as { modelId?: string } | undefined)?.modelId;
2905
2990
 
2906
2991
  // scanRealtimeContent no longer throws on the paths that used to (it
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.54.4",
3
+ "version": "4.54.7",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.54.4",
3
+ "version": "4.54.7",
4
4
  "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,7 +27,7 @@
27
27
  "prepublishOnly": "node -e \"if(!require('fs').existsSync('dist/index.js'))throw new Error('plugin dist/index.js missing — run `npm run build:ts` from the repo root before publishing')\""
28
28
  },
29
29
  "peerDependencies": {
30
- "shieldcortex": ">=4.18.3 <5.0.0",
30
+ "shieldcortex": "^4.54.6",
31
31
  "openclaw": ">=2026.3.22"
32
32
  },
33
33
  "peerDependenciesMeta": {