@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 +4 -0
- package/dist/index.js +77 -32
- package/dist/interceptor.js +117 -1
- package/dist/openclaw.plugin.json +1 -1
- package/index.ts +106 -32
- package/interceptor.ts +152 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
710
|
-
|
|
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
|
-
|
|
715
|
-
|
|
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.
|
|
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
|
-
|
|
739
|
-
api.
|
|
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",
|
package/dist/interceptor.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
830
|
-
|
|
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
|
-
|
|
834
|
-
|
|
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.
|
|
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
|
-
|
|
857
|
-
api.
|
|
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
|
-
|
|
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(' ');
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "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