@drakon-systems/shieldcortex-realtime 4.42.4 → 4.44.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
@@ -29,6 +29,7 @@ The defensive root `openclaw.plugin.json` is kept for one release on the main pa
29
29
  |------|--------|
30
30
  | `llm_input` | Scans prompts and history through the ShieldCortex defence pipeline. Threats are logged to audit and can forward to ShieldCortex Cloud. |
31
31
  | `llm_output` | Extracts high-signal memories from assistant replies and writes them into ShieldCortex with novelty filtering and dedupe. |
32
+ | `before_tool_call` | Runs the Action Guard before tools execute. Catastrophic shell/file/network/git actions are blocked; dangerous actions warn by default or request approval when enforcement is enabled. |
32
33
 
33
34
  The plugin is intentionally fire-and-forget: it should not stall the OpenClaw turn loop if ShieldCortex is unavailable.
34
35
 
@@ -82,6 +83,9 @@ Example:
82
83
  "entries": {
83
84
  "shieldcortex-realtime": {
84
85
  "enabled": true,
86
+ "hooks": {
87
+ "allowConversationAccess": true
88
+ },
85
89
  "config": {
86
90
  "binaryPath": "/usr/local/bin/shieldcortex",
87
91
  "openclawAutoMemory": true,
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * ShieldCortex Real-time Scanning Plugin for OpenClaw v2026.3.22+
3
3
  *
4
- * Uses explicit capability registration (registerHook + registerCommand)
5
- * for llm_input/llm_output scanning and optional memory extraction.
4
+ * Uses typed OpenClaw plugin hooks (`api.on`) for llm_input/llm_output
5
+ * scanning and before_tool_call interception. `api.registerHook` registers
6
+ * internal HOOK-style automation and does not participate in the agent-loop
7
+ * block/approval semantics ShieldCortex needs.
6
8
  * All scanning operations are fire-and-forget.
7
9
  */
8
10
  import { createHash } from "node:crypto";
@@ -640,6 +642,63 @@ function handleLlmOutput(event, ctx) {
640
642
  }
641
643
  })();
642
644
  }
645
+ class TypedApprovalRequest extends Error {
646
+ request;
647
+ constructor(message, request) {
648
+ super(message);
649
+ this.name = "TypedApprovalRequest";
650
+ this.request = request;
651
+ }
652
+ }
653
+ function truncateApprovalText(text, maxLength) {
654
+ const normalized = text.replace(/\s+/g, " ").trim();
655
+ if (normalized.length <= maxLength)
656
+ return normalized;
657
+ return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
658
+ }
659
+ function buildTypedApprovalRequest(message) {
660
+ const lines = message
661
+ .split(/\r?\n/u)
662
+ .map((line) => line.trim())
663
+ .filter(Boolean)
664
+ .filter((line) => !/^\[(?:Approve|Deny)\]/i.test(line));
665
+ const rawTitle = (lines[0] || "ShieldCortex approval required").replace(/^🛡️\s*/u, "");
666
+ const details = lines.slice(1).join(" | ") || rawTitle;
667
+ const riskText = message.toLowerCase();
668
+ const severity = /\b(?:critical|catastrophic|auto[-_\s]?deny|exfil|rm\s+-rf)\b/u.test(riskText)
669
+ ? "critical"
670
+ : /\b(?:high|dangerous|sensitive|risk|intercepted)\b/u.test(riskText)
671
+ ? "warning"
672
+ : "info";
673
+ return {
674
+ title: truncateApprovalText(rawTitle, 80),
675
+ description: truncateApprovalText(details, 256),
676
+ severity,
677
+ timeoutMs: 120_000,
678
+ timeoutBehavior: "deny",
679
+ allowedDecisions: ["allow-once", "deny"],
680
+ };
681
+ }
682
+ async function handleTypedBeforeToolCall(event, interceptor, logger) {
683
+ try {
684
+ await interceptor.handleToolCall({
685
+ toolName: event.toolName,
686
+ arguments: event.params ?? {},
687
+ requireApproval: async (message) => {
688
+ throw new TypedApprovalRequest(message, buildTypedApprovalRequest(message));
689
+ },
690
+ });
691
+ }
692
+ catch (err) {
693
+ if (err instanceof TypedApprovalRequest) {
694
+ return { requireApproval: err.request };
695
+ }
696
+ if (err instanceof Error && err.message.startsWith("ShieldCortex:")) {
697
+ return { block: true, blockReason: err.message };
698
+ }
699
+ logger?.warn?.(`[shieldcortex] before_tool_call error (allowing tool call): ${err instanceof Error ? err.message : err}`);
700
+ }
701
+ }
643
702
  // ==================== PLUGIN EXPORT ====================
644
703
  export default {
645
704
  id: PLUGIN_ID,
@@ -675,6 +734,7 @@ export default {
675
734
  enabled: rawInterceptorConfig.enabled ?? DEFAULT_INTERCEPTOR_CONFIG.enabled,
676
735
  severityActions: { ...DEFAULT_INTERCEPTOR_CONFIG.severityActions, ...rawInterceptorConfig.severityActions },
677
736
  failurePolicy: { ...DEFAULT_INTERCEPTOR_CONFIG.failurePolicy, ...rawInterceptorConfig.failurePolicy },
737
+ actionGuard: { ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: false }), ...(rawInterceptorConfig.actionGuard ?? {}) },
678
738
  } : {}),
679
739
  logger: { info: api.logger?.info ?? console.log, warn: api.logger?.warn ?? console.warn },
680
740
  };
@@ -692,13 +752,19 @@ export default {
692
752
  if (typeof defenceMod.runDefencePipeline !== 'function')
693
753
  return null;
694
754
  interceptorReady = createInterceptor(interceptorConfig, defenceMod.runDefencePipeline, {
755
+ evaluateToolCall: typeof defenceMod.evaluateToolCall === 'function'
756
+ ? defenceMod.evaluateToolCall
757
+ : undefined,
695
758
  onAuditEntry: (entry) => syncInterceptEvent(entry, {
696
759
  cloudApiKey: scConfig.cloudApiKey ?? '',
697
760
  cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
698
761
  cloudEnabled: scConfig.cloudEnabled ?? false,
699
762
  }),
700
763
  });
701
- api.logger?.info?.('[shieldcortex] Interceptor active — watching: remember, mcp__memory__remember');
764
+ const guardState = interceptorConfig.actionGuard?.enabled
765
+ ? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
766
+ : 'Action Guard: off';
767
+ api.logger?.info?.(`[shieldcortex] Interceptor active — memory writes + ${guardState} (shell/file/network/git)`);
702
768
  return interceptorReady;
703
769
  }
704
770
  catch (err) {
@@ -706,44 +772,23 @@ export default {
706
772
  return null;
707
773
  }
708
774
  }
709
- // Register before_tool_call with lazy-init wrapper
710
- api.registerHook('before_tool_call', async (context) => {
775
+ // Typed before_tool_call hook: this is the OpenClaw agent-loop gate that
776
+ // can block or require approval before the selected tool executes.
777
+ api.on('before_tool_call', async (event) => {
711
778
  const interceptor = await initInterceptor();
712
779
  if (!interceptor)
713
780
  return;
714
- try {
715
- await interceptor.handleToolCall(context);
716
- }
717
- catch (err) {
718
- // Intentional blocks from the interceptor (ShieldCortex: ...) should propagate
719
- if (err instanceof Error && err.message.startsWith('ShieldCortex:'))
720
- throw err;
721
- // Unexpected errors (DB crash, etc.) — log and allow the tool call through
722
- api.logger?.warn?.(`[shieldcortex] Interceptor error (allowing tool call): ${err instanceof Error ? err.message : err}`);
723
- }
724
- }, {
725
- name: 'shieldcortex-intercept-tool',
726
- description: 'Active threat gating on tool calls',
727
- });
781
+ return handleTypedBeforeToolCall(event, interceptor, api.logger);
782
+ }, { priority: 80, timeoutMs: 30_000 });
728
783
  // Try to register session_end for cache cleanup
729
784
  try {
730
- api.registerHook('session_end', () => { interceptorReady?.resetSession(); }, {
731
- name: 'shieldcortex-session-cleanup',
732
- description: 'Clear interceptor deny cache on session end',
733
- });
785
+ api.on('session_end', () => { interceptorReady?.resetSession(); });
734
786
  }
735
787
  catch {
736
788
  // session_end may not be a supported hook — TTL safety net handles this
737
789
  }
738
- // Explicit capability registration (replaces legacy api.on)
739
- api.registerHook("llm_input", handleLlmInput, {
740
- name: "shieldcortex-scan-input",
741
- description: "Real-time threat scanning on LLM input",
742
- });
743
- api.registerHook("llm_output", handleLlmOutput, {
744
- name: "shieldcortex-scan-output",
745
- description: "Memory extraction from LLM output",
746
- });
790
+ api.on("llm_input", handleLlmInput, { timeoutMs: 30_000 });
791
+ api.on("llm_output", handleLlmOutput, { timeoutMs: 30_000 });
747
792
  // Register a lightweight status command so the plugin is not hook-only
748
793
  api.registerCommand({
749
794
  name: "shieldcortex-status",
@@ -26,6 +26,12 @@ const DEFAULT_CONFIG = {
26
26
  high: 'deny',
27
27
  critical: 'deny',
28
28
  },
29
+ // Action Guard on by default: catastrophic ops are blocked out of the box;
30
+ // dangerous ops are surfaced (warn+audit) but allowed unless `enforce` is set.
31
+ actionGuard: {
32
+ enabled: true,
33
+ enforce: false,
34
+ },
29
35
  };
30
36
  export { WATCHED_TOOLS, CONTENT_FIELDS, DEFAULT_CONFIG };
31
37
  export function extractContent(toolName, args) {
@@ -133,6 +139,33 @@ export function formatApprovalPrompt(input) {
133
139
  '[Approve] [Deny]',
134
140
  ].join('\n');
135
141
  }
142
+ /** One-line summary of tool args for audit previews (bounded, no secrets dumped). */
143
+ export function summariseToolArgs(args) {
144
+ if (!args)
145
+ return '';
146
+ const parts = [];
147
+ for (const [k, val] of Object.entries(args)) {
148
+ if (typeof val === 'string')
149
+ parts.push(`${k}=${val.slice(0, 80)}`);
150
+ else if (typeof val === 'number' || typeof val === 'boolean')
151
+ parts.push(`${k}=${val}`);
152
+ }
153
+ return parts.join(' ').slice(0, 160);
154
+ }
155
+ /** Operator-facing approval prompt for a gated action (not a memory write). */
156
+ export function formatActionGuardPrompt(toolName, v) {
157
+ return [
158
+ '🛡️ ShieldCortex — Action Intercepted',
159
+ '',
160
+ `Tool: ${toolName}`,
161
+ `Action: ${v.action}`,
162
+ `Risk: ${v.severity}`,
163
+ `Signals: ${v.signals.join(', ') || 'none'}`,
164
+ `Reason: ${v.reason}`,
165
+ '',
166
+ '[Approve] [Deny]',
167
+ ].join('\n');
168
+ }
136
169
  // --- Audit Logging (local JSONL) ---
137
170
  const AUDIT_DIR = join(homedir(), '.shieldcortex', 'audit');
138
171
  function writeAuditEntry(entry) {
@@ -194,13 +227,96 @@ export function createInterceptor(config, pipeline, options) {
194
227
  const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
195
228
  const log = config.logger ?? { info: console.log, warn: console.warn };
196
229
  const onAuditEntry = options?.onAuditEntry;
230
+ const actionGuardCfg = config.actionGuard ?? { enabled: true, enforce: false };
231
+ const evaluateToolCall = options?.evaluateToolCall;
197
232
  function emitAudit(entry) {
198
233
  writeAuditEntry(entry);
199
234
  onAuditEntry?.(entry);
200
235
  }
236
+ function guardAuditBase(toolName, v, preview) {
237
+ return {
238
+ type: 'intercept', tool: toolName,
239
+ severity: v.severity === 'catastrophic' ? 'critical' : 'high',
240
+ firewallResult: 'ACTION_GUARD', threats: v.signals,
241
+ anomalyScore: v.decision === 'block' ? 1 : 0.6,
242
+ trustScore: 0, sensitivityLevel: 'INTERNAL', fragmentationScore: null, pipelineDurationMs: 0,
243
+ preview: preview.slice(0, 200), ts: new Date().toISOString(),
244
+ };
245
+ }
246
+ // Action Guard: gates non-memory tool calls (shell / file / network / git).
247
+ // This is what makes "Iron Dome protects what the agent DOES" true at runtime.
248
+ async function runActionGuard(context) {
249
+ if (!actionGuardCfg.enabled || typeof evaluateToolCall !== 'function')
250
+ return;
251
+ let v;
252
+ try {
253
+ v = evaluateToolCall(context.toolName, context.arguments || {});
254
+ }
255
+ catch (err) {
256
+ // A guard error must never break the agent — log and allow. (The memory
257
+ // pipeline is the hard-fail path; the action guard is best-effort.)
258
+ log.warn(`[shieldcortex] ⚠️ action-guard error (allowing ${context.toolName}): ${err instanceof Error ? err.message : err}`);
259
+ return;
260
+ }
261
+ if (v.decision === 'allow')
262
+ return;
263
+ const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
264
+ const base = guardAuditBase(context.toolName, v, preview);
265
+ const severity = v.severity === 'catastrophic' ? 'critical' : 'high';
266
+ // Catastrophic / exfil — hard block, always enforced when the guard is enabled.
267
+ if (v.decision === 'block') {
268
+ emitAudit({ ...base, action: 'auto_deny', outcome: 'auto_denied' });
269
+ // Surface the block to the gateway log (journald). Blocks are recorded in
270
+ // the ShieldCortex audit jsonl, but were otherwise invisible to an operator
271
+ // tailing the gateway; a denial — especially a false positive — must be seen.
272
+ log.warn(`[shieldcortex] action-guard BLOCKED ${context.toolName}: ${v.reason} [${v.signals.join(", ")}]`);
273
+ throw new Error(`ShieldCortex: tool call blocked — ${v.reason}`);
274
+ }
275
+ // require_approval — warn-only by default (never nags), prompt when enforcing.
276
+ if (!actionGuardCfg.enforce) {
277
+ log.warn(`[shieldcortex] ⚠️ Action Guard: ${context.toolName} — ${v.reason}`);
278
+ emitAudit({ ...base, action: 'warn', outcome: 'warned' });
279
+ return;
280
+ }
281
+ if (typeof context.requireApproval !== 'function') {
282
+ const failAction = config.failurePolicy[severity];
283
+ emitAudit({ ...base, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
284
+ if (failAction === 'deny') {
285
+ throw new Error(`ShieldCortex: tool call blocked — ${v.reason} (no approver, failure policy: deny)`);
286
+ }
287
+ return;
288
+ }
289
+ if (!rateLimiter.shouldAllow()) {
290
+ emitAudit({ ...base, action: 'rate_limit', outcome: 'auto_denied' });
291
+ throw new Error('ShieldCortex: tool call auto-denied (approval rate limit exceeded)');
292
+ }
293
+ let approved;
294
+ try {
295
+ approved = await context.requireApproval(formatActionGuardPrompt(context.toolName, v));
296
+ }
297
+ catch (err) {
298
+ const failAction = config.failurePolicy[severity];
299
+ log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
300
+ emitAudit({ ...base, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
301
+ if (failAction === 'deny') {
302
+ throw new Error('ShieldCortex: tool call blocked — approval error, failure policy: deny');
303
+ }
304
+ return;
305
+ }
306
+ if (approved) {
307
+ emitAudit({ ...base, action: 'require_approval', outcome: 'approved' });
308
+ return;
309
+ }
310
+ emitAudit({ ...base, action: 'require_approval', outcome: 'denied' });
311
+ throw new Error('ShieldCortex: tool call denied by user');
312
+ }
201
313
  async function handleToolCall(context) {
202
- if (!WATCHED_TOOLS.includes(context.toolName))
314
+ // Non-memory tools go through the Action Guard (what the agent DOES); the
315
+ // memory-write tools continue through the content defence pipeline below.
316
+ if (!WATCHED_TOOLS.includes(context.toolName)) {
317
+ await runActionGuard(context);
203
318
  return;
319
+ }
204
320
  const { title, content } = extractContent(context.toolName, context.arguments);
205
321
  const fullContent = [title, content].filter(Boolean).join(' ');
206
322
  if (!fullContent.trim())
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.42.4",
3
+ "version": "4.44.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/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * ShieldCortex Real-time Scanning Plugin for OpenClaw v2026.3.22+
3
3
  *
4
- * Uses explicit capability registration (registerHook + registerCommand)
5
- * for llm_input/llm_output scanning and optional memory extraction.
4
+ * Uses typed OpenClaw plugin hooks (`api.on`) for llm_input/llm_output
5
+ * scanning and before_tool_call interception. `api.registerHook` registers
6
+ * internal HOOK-style automation and does not participate in the agent-loop
7
+ * block/approval semantics ShieldCortex needs.
6
8
  * All scanning operations are fire-and-forget.
7
9
  */
8
10
 
@@ -14,7 +16,7 @@ import { homedir } from "node:os";
14
16
  import { fileURLToPath, pathToFileURL } from "node:url";
15
17
 
16
18
  import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
17
- import type { InterceptorConfig, ToolCallContext } from './interceptor.js';
19
+ import type { InterceptorConfig } from './interceptor.js';
18
20
  import { syncInterceptEvent } from './intercept-ingest.js';
19
21
  import { cloudSync } from './cloud-sync.js';
20
22
 
@@ -190,9 +192,26 @@ type AgentCtx = {
190
192
  agentId?: string; sessionKey?: string; sessionId?: string;
191
193
  workspaceDir?: string; messageProvider?: string;
192
194
  };
195
+ type TypedBeforeToolCallEvent = {
196
+ toolName: string;
197
+ params?: Record<string, unknown>;
198
+ };
199
+ type TypedBeforeToolCallResult = {
200
+ block?: boolean;
201
+ blockReason?: string;
202
+ requireApproval?: {
203
+ title: string;
204
+ description: string;
205
+ severity?: "info" | "warning" | "critical";
206
+ timeoutMs?: number;
207
+ timeoutBehavior?: "allow" | "deny";
208
+ allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
209
+ onResolution?: (decision: "allow-once" | "allow-always" | "deny" | "timeout" | "cancelled") => Promise<void> | void;
210
+ };
211
+ };
193
212
  type PluginApi = {
194
213
  id: string; name: string; logger: { info: (m: string) => void };
195
- on: (hook: string, handler: (...args: any[]) => any) => void;
214
+ on: (hook: string, handler: (...args: any[]) => any, opts?: Record<string, unknown>) => void;
196
215
  [k: string]: any;
197
216
  };
198
217
 
@@ -756,6 +775,73 @@ function handleLlmOutput(event: LlmOutputEvent, ctx: AgentCtx): void {
756
775
  })();
757
776
  }
758
777
 
778
+ class TypedApprovalRequest extends Error {
779
+ request: NonNullable<TypedBeforeToolCallResult["requireApproval"]>;
780
+
781
+ constructor(message: string, request: NonNullable<TypedBeforeToolCallResult["requireApproval"]>) {
782
+ super(message);
783
+ this.name = "TypedApprovalRequest";
784
+ this.request = request;
785
+ }
786
+ }
787
+
788
+ function truncateApprovalText(text: string, maxLength: number): string {
789
+ const normalized = text.replace(/\s+/g, " ").trim();
790
+ if (normalized.length <= maxLength) return normalized;
791
+ return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
792
+ }
793
+
794
+ function buildTypedApprovalRequest(message: string): NonNullable<TypedBeforeToolCallResult["requireApproval"]> {
795
+ const lines = message
796
+ .split(/\r?\n/u)
797
+ .map((line) => line.trim())
798
+ .filter(Boolean)
799
+ .filter((line) => !/^\[(?:Approve|Deny)\]/i.test(line));
800
+ const rawTitle = (lines[0] || "ShieldCortex approval required").replace(/^🛡️\s*/u, "");
801
+ const details = lines.slice(1).join(" | ") || rawTitle;
802
+ const riskText = message.toLowerCase();
803
+ const severity = /\b(?:critical|catastrophic|auto[-_\s]?deny|exfil|rm\s+-rf)\b/u.test(riskText)
804
+ ? "critical"
805
+ : /\b(?:high|dangerous|sensitive|risk|intercepted)\b/u.test(riskText)
806
+ ? "warning"
807
+ : "info";
808
+
809
+ return {
810
+ title: truncateApprovalText(rawTitle, 80),
811
+ description: truncateApprovalText(details, 256),
812
+ severity,
813
+ timeoutMs: 120_000,
814
+ timeoutBehavior: "deny",
815
+ allowedDecisions: ["allow-once", "deny"],
816
+ };
817
+ }
818
+
819
+ async function handleTypedBeforeToolCall(
820
+ event: TypedBeforeToolCallEvent,
821
+ interceptor: ReturnType<typeof createInterceptor>,
822
+ logger: PluginApi["logger"],
823
+ ): Promise<TypedBeforeToolCallResult | void> {
824
+ try {
825
+ await interceptor.handleToolCall({
826
+ toolName: event.toolName,
827
+ arguments: event.params ?? {},
828
+ requireApproval: async (message: string) => {
829
+ throw new TypedApprovalRequest(message, buildTypedApprovalRequest(message));
830
+ },
831
+ });
832
+ } catch (err) {
833
+ if (err instanceof TypedApprovalRequest) {
834
+ return { requireApproval: err.request };
835
+ }
836
+
837
+ if (err instanceof Error && err.message.startsWith("ShieldCortex:")) {
838
+ return { block: true, blockReason: err.message };
839
+ }
840
+
841
+ (logger as any)?.warn?.(`[shieldcortex] before_tool_call error (allowing tool call): ${err instanceof Error ? err.message : err}`);
842
+ }
843
+ }
844
+
759
845
  // ==================== PLUGIN EXPORT ====================
760
846
 
761
847
  export default {
@@ -794,6 +880,7 @@ export default {
794
880
  enabled: rawInterceptorConfig.enabled ?? DEFAULT_INTERCEPTOR_CONFIG.enabled,
795
881
  severityActions: { ...DEFAULT_INTERCEPTOR_CONFIG.severityActions, ...rawInterceptorConfig.severityActions },
796
882
  failurePolicy: { ...DEFAULT_INTERCEPTOR_CONFIG.failurePolicy, ...rawInterceptorConfig.failurePolicy },
883
+ actionGuard: { ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: false }), ...(rawInterceptorConfig.actionGuard ?? {}) },
797
884
  } : {}),
798
885
  logger: { info: api.logger?.info ?? console.log, warn: (api.logger as any)?.warn ?? console.warn },
799
886
  };
@@ -812,13 +899,19 @@ export default {
812
899
  if (typeof defenceMod.runDefencePipeline !== 'function') return null;
813
900
 
814
901
  interceptorReady = createInterceptor(interceptorConfig, defenceMod.runDefencePipeline as Parameters<typeof createInterceptor>[1], {
902
+ evaluateToolCall: typeof (defenceMod as any).evaluateToolCall === 'function'
903
+ ? ((defenceMod as any).evaluateToolCall as Parameters<typeof createInterceptor>[2] extends { evaluateToolCall?: infer E } ? E : never)
904
+ : undefined,
815
905
  onAuditEntry: (entry) => syncInterceptEvent(entry, {
816
906
  cloudApiKey: (scConfig as any).cloudApiKey ?? '',
817
907
  cloudBaseUrl: (scConfig as any).cloudBaseUrl ?? 'https://api.shieldcortex.ai',
818
908
  cloudEnabled: (scConfig as any).cloudEnabled ?? false,
819
909
  }),
820
910
  });
821
- api.logger?.info?.('[shieldcortex] Interceptor active — watching: remember, mcp__memory__remember');
911
+ const guardState = interceptorConfig.actionGuard?.enabled
912
+ ? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
913
+ : 'Action Guard: off';
914
+ api.logger?.info?.(`[shieldcortex] Interceptor active — memory writes + ${guardState} (shell/file/network/git)`);
822
915
  return interceptorReady;
823
916
  } catch (err) {
824
917
  (api.logger as any)?.warn?.(`[shieldcortex] Interceptor init failed: ${err instanceof Error ? err.message : err}`);
@@ -826,42 +919,23 @@ export default {
826
919
  }
827
920
  }
828
921
 
829
- // Register before_tool_call with lazy-init wrapper
830
- api.registerHook('before_tool_call', async (context: ToolCallContext) => {
922
+ // Typed before_tool_call hook: this is the OpenClaw agent-loop gate that
923
+ // can block or require approval before the selected tool executes.
924
+ api.on('before_tool_call', async (event: TypedBeforeToolCallEvent) => {
831
925
  const interceptor = await initInterceptor();
832
926
  if (!interceptor) return;
833
- try {
834
- await interceptor.handleToolCall(context);
835
- } catch (err) {
836
- // Intentional blocks from the interceptor (ShieldCortex: ...) should propagate
837
- if (err instanceof Error && err.message.startsWith('ShieldCortex:')) throw err;
838
- // Unexpected errors (DB crash, etc.) — log and allow the tool call through
839
- (api.logger as any)?.warn?.(`[shieldcortex] Interceptor error (allowing tool call): ${err instanceof Error ? err.message : err}`);
840
- }
841
- }, {
842
- name: 'shieldcortex-intercept-tool',
843
- description: 'Active threat gating on tool calls',
844
- });
927
+ return handleTypedBeforeToolCall(event, interceptor, api.logger);
928
+ }, { priority: 80, timeoutMs: 30_000 });
845
929
 
846
930
  // Try to register session_end for cache cleanup
847
931
  try {
848
- api.registerHook('session_end', () => { interceptorReady?.resetSession(); }, {
849
- name: 'shieldcortex-session-cleanup',
850
- description: 'Clear interceptor deny cache on session end',
851
- });
932
+ api.on('session_end', () => { interceptorReady?.resetSession(); });
852
933
  } catch {
853
934
  // session_end may not be a supported hook — TTL safety net handles this
854
935
  }
855
936
 
856
- // Explicit capability registration (replaces legacy api.on)
857
- api.registerHook("llm_input", handleLlmInput, {
858
- name: "shieldcortex-scan-input",
859
- description: "Real-time threat scanning on LLM input",
860
- });
861
- api.registerHook("llm_output", handleLlmOutput, {
862
- name: "shieldcortex-scan-output",
863
- description: "Memory extraction from LLM output",
864
- });
937
+ api.on("llm_input", handleLlmInput, { timeoutMs: 30_000 });
938
+ api.on("llm_output", handleLlmOutput, { timeoutMs: 30_000 });
865
939
 
866
940
  // Register a lightweight status command so the plugin is not hook-only
867
941
  api.registerCommand({
package/interceptor.ts CHANGED
@@ -7,10 +7,37 @@ export type Severity = 'low' | 'medium' | 'high' | 'critical';
7
7
  export type InterceptAction = 'log' | 'warn' | 'require_approval';
8
8
  export type FailureAction = 'allow' | 'deny';
9
9
 
10
+ /**
11
+ * Action Guard config — gates what the agent DOES (shell/file/network/git),
12
+ * not just what it remembers. Catastrophic operations (rm -rf /, fork bombs,
13
+ * disk wipes, secret exfil) are always blocked when `enabled`; recognised
14
+ * dangerous ops require approval when `enforce` is set, otherwise they are
15
+ * surfaced (warn + audit) but allowed through — so the guard never nags on
16
+ * routine work by default.
17
+ */
18
+ export interface ActionGuardConfig {
19
+ enabled: boolean;
20
+ enforce: boolean;
21
+ }
22
+
23
+ /** Structural shape of a Tool Action Guard verdict (kept local to avoid a
24
+ * compile-time dependency on the main package across the plugin build boundary;
25
+ * the real `evaluateToolCall` from `shieldcortex/defence` is compatible). */
26
+ export interface ToolGuardVerdictLike {
27
+ decision: 'allow' | 'require_approval' | 'block';
28
+ severity: 'benign' | 'sensitive' | 'dangerous' | 'catastrophic' | string;
29
+ family: string;
30
+ action: string;
31
+ reason: string;
32
+ signals: string[];
33
+ }
34
+ export type ToolGuardEvaluator = (toolName: string, args: Record<string, unknown>) => ToolGuardVerdictLike;
35
+
10
36
  export interface InterceptorConfig {
11
37
  enabled: boolean;
12
38
  severityActions: Record<Severity, InterceptAction>;
13
39
  failurePolicy: Record<Severity, FailureAction>;
40
+ actionGuard?: ActionGuardConfig;
14
41
  logger?: { info: (msg: string) => void; warn: (msg: string) => void };
15
42
  }
16
43
 
@@ -63,6 +90,12 @@ const DEFAULT_CONFIG: InterceptorConfig = {
63
90
  high: 'deny',
64
91
  critical: 'deny',
65
92
  },
93
+ // Action Guard on by default: catastrophic ops are blocked out of the box;
94
+ // dangerous ops are surfaced (warn+audit) but allowed unless `enforce` is set.
95
+ actionGuard: {
96
+ enabled: true,
97
+ enforce: false,
98
+ },
66
99
  };
67
100
 
68
101
  export { WATCHED_TOOLS, CONTENT_FIELDS, DEFAULT_CONFIG };
@@ -202,6 +235,32 @@ export function formatApprovalPrompt(input: ApprovalPromptInput): string {
202
235
  ].join('\n');
203
236
  }
204
237
 
238
+ /** One-line summary of tool args for audit previews (bounded, no secrets dumped). */
239
+ export function summariseToolArgs(args: Record<string, unknown> | undefined): string {
240
+ if (!args) return '';
241
+ const parts: string[] = [];
242
+ for (const [k, val] of Object.entries(args)) {
243
+ if (typeof val === 'string') parts.push(`${k}=${val.slice(0, 80)}`);
244
+ else if (typeof val === 'number' || typeof val === 'boolean') parts.push(`${k}=${val}`);
245
+ }
246
+ return parts.join(' ').slice(0, 160);
247
+ }
248
+
249
+ /** Operator-facing approval prompt for a gated action (not a memory write). */
250
+ export function formatActionGuardPrompt(toolName: string, v: ToolGuardVerdictLike): string {
251
+ return [
252
+ '🛡️ ShieldCortex — Action Intercepted',
253
+ '',
254
+ `Tool: ${toolName}`,
255
+ `Action: ${v.action}`,
256
+ `Risk: ${v.severity}`,
257
+ `Signals: ${v.signals.join(', ') || 'none'}`,
258
+ `Reason: ${v.reason}`,
259
+ '',
260
+ '[Approve] [Deny]',
261
+ ].join('\n');
262
+ }
263
+
205
264
  // --- Audit Logging (local JSONL) ---
206
265
 
207
266
  const AUDIT_DIR = join(homedir(), '.shieldcortex', 'audit');
@@ -297,6 +356,8 @@ type PipelineRunner = (content: string, title: string, source: { type: string; i
297
356
  interface InterceptorOptions {
298
357
  maxPromptsPerMinute?: number;
299
358
  onAuditEntry?: (entry: InterceptAuditEntry) => void;
359
+ /** Tool Action Guard evaluator, injected from `shieldcortex/defence` at runtime. */
360
+ evaluateToolCall?: ToolGuardEvaluator;
300
361
  }
301
362
 
302
363
  export function createInterceptor(
@@ -311,14 +372,104 @@ export function createInterceptor(
311
372
  const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
312
373
  const log = config.logger ?? { info: console.log, warn: console.warn };
313
374
  const onAuditEntry = options?.onAuditEntry;
375
+ const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled: true, enforce: false };
376
+ const evaluateToolCall = options?.evaluateToolCall;
314
377
 
315
378
  function emitAudit(entry: InterceptAuditEntry): void {
316
379
  writeAuditEntry(entry);
317
380
  onAuditEntry?.(entry);
318
381
  }
319
382
 
383
+ function guardAuditBase(toolName: string, v: ToolGuardVerdictLike, preview: string): Omit<InterceptAuditEntry, 'action' | 'outcome'> {
384
+ return {
385
+ type: 'intercept', tool: toolName,
386
+ severity: v.severity === 'catastrophic' ? 'critical' : 'high',
387
+ firewallResult: 'ACTION_GUARD', threats: v.signals,
388
+ anomalyScore: v.decision === 'block' ? 1 : 0.6,
389
+ trustScore: 0, sensitivityLevel: 'INTERNAL', fragmentationScore: null, pipelineDurationMs: 0,
390
+ preview: preview.slice(0, 200), ts: new Date().toISOString(),
391
+ };
392
+ }
393
+
394
+ // Action Guard: gates non-memory tool calls (shell / file / network / git).
395
+ // This is what makes "Iron Dome protects what the agent DOES" true at runtime.
396
+ async function runActionGuard(context: ToolCallContext): Promise<void> {
397
+ if (!actionGuardCfg.enabled || typeof evaluateToolCall !== 'function') return;
398
+
399
+ let v: ToolGuardVerdictLike;
400
+ try {
401
+ v = evaluateToolCall(context.toolName, context.arguments || {});
402
+ } catch (err) {
403
+ // A guard error must never break the agent — log and allow. (The memory
404
+ // pipeline is the hard-fail path; the action guard is best-effort.)
405
+ log.warn(`[shieldcortex] ⚠️ action-guard error (allowing ${context.toolName}): ${err instanceof Error ? err.message : err}`);
406
+ return;
407
+ }
408
+ if (v.decision === 'allow') return;
409
+
410
+ const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
411
+ const base = guardAuditBase(context.toolName, v, preview);
412
+ const severity: Severity = v.severity === 'catastrophic' ? 'critical' : 'high';
413
+
414
+ // Catastrophic / exfil — hard block, always enforced when the guard is enabled.
415
+ if (v.decision === 'block') {
416
+ emitAudit({ ...base, action: 'auto_deny', outcome: 'auto_denied' });
417
+ // Surface the block to the gateway log (journald). Blocks are recorded in
418
+ // the ShieldCortex audit jsonl, but were otherwise invisible to an operator
419
+ // tailing the gateway; a denial — especially a false positive — must be seen.
420
+ log.warn(`[shieldcortex] action-guard BLOCKED ${context.toolName}: ${v.reason} [${v.signals.join(", ")}]`);
421
+ throw new Error(`ShieldCortex: tool call blocked — ${v.reason}`);
422
+ }
423
+
424
+ // require_approval — warn-only by default (never nags), prompt when enforcing.
425
+ if (!actionGuardCfg.enforce) {
426
+ log.warn(`[shieldcortex] ⚠️ Action Guard: ${context.toolName} — ${v.reason}`);
427
+ emitAudit({ ...base, action: 'warn', outcome: 'warned' });
428
+ return;
429
+ }
430
+
431
+ if (typeof context.requireApproval !== 'function') {
432
+ const failAction = config.failurePolicy[severity];
433
+ emitAudit({ ...base, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
434
+ if (failAction === 'deny') {
435
+ throw new Error(`ShieldCortex: tool call blocked — ${v.reason} (no approver, failure policy: deny)`);
436
+ }
437
+ return;
438
+ }
439
+
440
+ if (!rateLimiter.shouldAllow()) {
441
+ emitAudit({ ...base, action: 'rate_limit', outcome: 'auto_denied' });
442
+ throw new Error('ShieldCortex: tool call auto-denied (approval rate limit exceeded)');
443
+ }
444
+
445
+ let approved: boolean;
446
+ try {
447
+ approved = await context.requireApproval(formatActionGuardPrompt(context.toolName, v));
448
+ } catch (err) {
449
+ const failAction = config.failurePolicy[severity];
450
+ log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
451
+ emitAudit({ ...base, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
452
+ if (failAction === 'deny') {
453
+ throw new Error('ShieldCortex: tool call blocked — approval error, failure policy: deny');
454
+ }
455
+ return;
456
+ }
457
+
458
+ if (approved) {
459
+ emitAudit({ ...base, action: 'require_approval', outcome: 'approved' });
460
+ return;
461
+ }
462
+ emitAudit({ ...base, action: 'require_approval', outcome: 'denied' });
463
+ throw new Error('ShieldCortex: tool call denied by user');
464
+ }
465
+
320
466
  async function handleToolCall(context: ToolCallContext): Promise<void> {
321
- if (!(WATCHED_TOOLS as readonly string[]).includes(context.toolName)) return;
467
+ // Non-memory tools go through the Action Guard (what the agent DOES); the
468
+ // memory-write tools continue through the content defence pipeline below.
469
+ if (!(WATCHED_TOOLS as readonly string[]).includes(context.toolName)) {
470
+ await runActionGuard(context);
471
+ return;
472
+ }
322
473
 
323
474
  const { title, content } = extractContent(context.toolName, context.arguments);
324
475
  const fullContent = [title, content].filter(Boolean).join(' ');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.42.4",
3
+ "version": "4.44.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.42.4",
3
+ "version": "4.44.0",
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",