@drakon-systems/shieldcortex-realtime 4.47.18 → 4.47.19
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/broker-invoker.js +118 -0
- package/dist/index.js +81 -0
- package/dist/interceptor.js +197 -7
- package/dist/openclaw.plugin.json +1 -1
- package/index.ts +95 -1
- package/interceptor.ts +299 -7
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShieldCortex — the OpenClaw judge transport for the approval broker (#143).
|
|
3
|
+
*
|
|
4
|
+
* Design: docs/design/2026-07-31-ai-approval-broker.md
|
|
5
|
+
*
|
|
6
|
+
* The design decided (Michael, 31 Jul 2026) that the broker rides the host's
|
|
7
|
+
* existing model pool: no API key of its own, no login, no second bill. On the
|
|
8
|
+
* gateway that means "ask OpenClaw for one completion through the pool the
|
|
9
|
+
* operator already pays for and already governs".
|
|
10
|
+
*
|
|
11
|
+
* The gateway does not expose a completion seam to plugins today. Rather than
|
|
12
|
+
* inventing credentials or importing an SDK — either of which would make
|
|
13
|
+
* ShieldCortex a second, unaudited path to a model using the operator's
|
|
14
|
+
* money — this defines the narrowest seam that could satisfy the design and
|
|
15
|
+
* fails closed when it is absent:
|
|
16
|
+
*
|
|
17
|
+
* no `context.invokeModel` → no invoker → no judge → the broker HOLDS.
|
|
18
|
+
*
|
|
19
|
+
* Which is exactly today's behaviour: the operator gets asked. When a gateway
|
|
20
|
+
* build starts offering `invokeModel`, the broker lights up with no change here.
|
|
21
|
+
*
|
|
22
|
+
* The second half of the contract is what the seam is NOT given. The request is
|
|
23
|
+
* a system prompt and one block of text, and the exported key allowlist is
|
|
24
|
+
* asserted by test — because the failure mode this guards against is somebody
|
|
25
|
+
* later adding `messages` or `session` "for context" and quietly handing the
|
|
26
|
+
* judge the poisoned transcript it exists to be immune to.
|
|
27
|
+
*
|
|
28
|
+
* Types are declared locally rather than imported from `shieldcortex/defence`:
|
|
29
|
+
* this file is built by tsconfig.openclaw-plugin.json across the plugin
|
|
30
|
+
* boundary, the same reason `ToolGuardVerdictLike` is structural in
|
|
31
|
+
* interceptor.ts.
|
|
32
|
+
*/
|
|
33
|
+
/** The exact key set of a request. Pinned by test — see the header. */
|
|
34
|
+
export const GATEWAY_REQUEST_KEYS = ['system', 'prompt', 'toolless', 'model', 'timeoutMs'];
|
|
35
|
+
/** A judge reply is one small JSON object; anything past this is noise. */
|
|
36
|
+
const MAX_COMPLETION_CHARS = 65_536;
|
|
37
|
+
/** Duplicated from broker-config.ts's MODEL_NAME — the plugin build cannot
|
|
38
|
+
* import across the package boundary, and a hostile model string must be
|
|
39
|
+
* refused on both sides of it. Kept in sync there. */
|
|
40
|
+
const MODEL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/;
|
|
41
|
+
function safeModel(model) {
|
|
42
|
+
if (typeof model !== 'string')
|
|
43
|
+
return undefined;
|
|
44
|
+
const trimmed = model.trim();
|
|
45
|
+
if (!trimmed || trimmed.length > 64 || !MODEL_NAME.test(trimmed))
|
|
46
|
+
return undefined;
|
|
47
|
+
return trimmed;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Read text out of whatever the host's pool returned.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately permissive about SHAPE (a gateway may hand back a string, an
|
|
53
|
+
* Anthropic-style content array, or an OpenAI-style message) and deliberately
|
|
54
|
+
* strict about SUBSTANCE: anything that is not readable text is null, and null
|
|
55
|
+
* becomes a rejection, which becomes a hold. A response we cannot read is never
|
|
56
|
+
* optimistically treated as approval.
|
|
57
|
+
*/
|
|
58
|
+
export function coerceCompletion(raw) {
|
|
59
|
+
const bound = (s) => (s.length > MAX_COMPLETION_CHARS ? s.slice(0, MAX_COMPLETION_CHARS) : s);
|
|
60
|
+
if (typeof raw === 'string')
|
|
61
|
+
return bound(raw);
|
|
62
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
63
|
+
return null;
|
|
64
|
+
const o = raw;
|
|
65
|
+
for (const key of ['text', 'completion', 'output']) {
|
|
66
|
+
if (typeof o[key] === 'string')
|
|
67
|
+
return bound(o[key]);
|
|
68
|
+
}
|
|
69
|
+
if (typeof o.content === 'string')
|
|
70
|
+
return bound(o.content);
|
|
71
|
+
if (Array.isArray(o.content)) {
|
|
72
|
+
const parts = o.content
|
|
73
|
+
.filter((b) => !!b && typeof b === 'object')
|
|
74
|
+
.filter(b => b.type === 'text' && typeof b.text === 'string')
|
|
75
|
+
.map(b => b.text);
|
|
76
|
+
return parts.length ? bound(parts.join('')) : null;
|
|
77
|
+
}
|
|
78
|
+
if (o.message && typeof o.message === 'object')
|
|
79
|
+
return coerceCompletion(o.message);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build a judge transport from the gateway's completion seam, or null when the
|
|
84
|
+
* gateway does not offer one.
|
|
85
|
+
*
|
|
86
|
+
* Null is not an error state — it is the honest answer on every gateway build
|
|
87
|
+
* that ships today, and the broker's response to it (hold for the operator) is
|
|
88
|
+
* the behaviour ShieldCortex already had.
|
|
89
|
+
*/
|
|
90
|
+
export function createGatewayInvoker(context, opts = {}) {
|
|
91
|
+
if (!context || typeof context !== 'object')
|
|
92
|
+
return null;
|
|
93
|
+
const invokeModel = context.invokeModel;
|
|
94
|
+
if (typeof invokeModel !== 'function')
|
|
95
|
+
return null;
|
|
96
|
+
const model = safeModel(opts.model);
|
|
97
|
+
return async (system, user) => {
|
|
98
|
+
const req = {
|
|
99
|
+
system,
|
|
100
|
+
prompt: user,
|
|
101
|
+
toolless: true,
|
|
102
|
+
};
|
|
103
|
+
if (model)
|
|
104
|
+
req.model = model;
|
|
105
|
+
if (typeof opts.timeoutMs === 'number' && Number.isFinite(opts.timeoutMs))
|
|
106
|
+
req.timeoutMs = opts.timeoutMs;
|
|
107
|
+
// A throw here propagates: runJudge catches it and returns null, which the
|
|
108
|
+
// broker reads as "hold". Swallowing it into a default would be the one
|
|
109
|
+
// failure mode this whole layer exists to avoid.
|
|
110
|
+
const raw = await invokeModel(req);
|
|
111
|
+
const text = coerceCompletion(raw);
|
|
112
|
+
if (text === null)
|
|
113
|
+
throw new Error('gateway returned an unreadable completion');
|
|
114
|
+
if (!text.trim())
|
|
115
|
+
throw new Error('gateway returned an empty completion');
|
|
116
|
+
return text;
|
|
117
|
+
};
|
|
118
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -215,6 +215,16 @@ const PLUGIN_CONFIG_UI_HINTS = {
|
|
|
215
215
|
help: "Write an audit entry when the guard evaluates a recognised operation and allows it.",
|
|
216
216
|
advanced: true,
|
|
217
217
|
},
|
|
218
|
+
"interceptor.actionGuard.broker.enabled": {
|
|
219
|
+
label: "AI Approval Broker",
|
|
220
|
+
help: "Let a fast model judge dangerous-tier approvals before they reach you: it can deny outright when it sees injection, and release reversible, in-context, high-confidence actions without waiting. Never applies to catastrophic operations. Off by default.",
|
|
221
|
+
advanced: true,
|
|
222
|
+
},
|
|
223
|
+
"interceptor.actionGuard.broker.allowPreClear": {
|
|
224
|
+
label: "Allow Broker Pre-clear",
|
|
225
|
+
help: "Off = every dangerous-tier action still waits for you; the broker can then only harden, never release.",
|
|
226
|
+
advanced: true,
|
|
227
|
+
},
|
|
218
228
|
};
|
|
219
229
|
const SEVERITY_ACTION_SCHEMA = {
|
|
220
230
|
type: "object",
|
|
@@ -241,6 +251,28 @@ const INTERCEPTOR_JSON_SCHEMA = {
|
|
|
241
251
|
enforce: { type: "boolean" },
|
|
242
252
|
autoApprove: { type: "array", items: { type: "string" } },
|
|
243
253
|
auditAllows: { type: "boolean" },
|
|
254
|
+
// #143. Mirrors normaliseBrokerConfig's allowlist; that function still
|
|
255
|
+
// has the last word, so a value that slips past the schema is still
|
|
256
|
+
// range-checked (and dropped) before the broker sees it.
|
|
257
|
+
broker: {
|
|
258
|
+
type: "object",
|
|
259
|
+
additionalProperties: false,
|
|
260
|
+
properties: {
|
|
261
|
+
enabled: { type: "boolean" },
|
|
262
|
+
allowPreClear: { type: "boolean" },
|
|
263
|
+
preClearConfidence: { type: "number", minimum: 0.9, maximum: 1 },
|
|
264
|
+
judgeTimeoutMs: { type: "number", minimum: 500, maximum: 60000 },
|
|
265
|
+
approvalTimeoutMs: {
|
|
266
|
+
type: "object",
|
|
267
|
+
additionalProperties: false,
|
|
268
|
+
properties: {
|
|
269
|
+
sensitive: { type: "number", minimum: 1000, maximum: 3600000 },
|
|
270
|
+
dangerous: { type: "number", minimum: 1000, maximum: 3600000 },
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
model: { type: "string" },
|
|
274
|
+
},
|
|
275
|
+
},
|
|
244
276
|
},
|
|
245
277
|
},
|
|
246
278
|
},
|
|
@@ -375,6 +407,12 @@ function normaliseInterceptorConfig(raw) {
|
|
|
375
407
|
if (Array.isArray(rawGuard.autoApprove) && rawGuard.autoApprove.every((entry) => typeof entry === "string")) {
|
|
376
408
|
guard.autoApprove = rawGuard.autoApprove;
|
|
377
409
|
}
|
|
410
|
+
// Carried through untouched — normaliseBrokerConfig is the boundary, and
|
|
411
|
+
// splitting that job across two files is how one of the halves ends up
|
|
412
|
+
// being the lenient one.
|
|
413
|
+
if (rawGuard.broker && typeof rawGuard.broker === "object" && !Array.isArray(rawGuard.broker)) {
|
|
414
|
+
guard.broker = rawGuard.broker;
|
|
415
|
+
}
|
|
378
416
|
if (Object.keys(guard).length > 0)
|
|
379
417
|
out.actionGuard = guard;
|
|
380
418
|
}
|
|
@@ -848,6 +886,48 @@ async function handleTypedBeforeToolCall(event, interceptor, logger) {
|
|
|
848
886
|
}
|
|
849
887
|
}
|
|
850
888
|
// ==================== PLUGIN EXPORT ====================
|
|
889
|
+
/**
|
|
890
|
+
* Assemble the approval broker (#143) from the main package, or return
|
|
891
|
+
* undefined.
|
|
892
|
+
*
|
|
893
|
+
* Undefined is the normal answer and a safe one: no broker means the guard
|
|
894
|
+
* behaves exactly as it did before #143 — every dangerous-tier call goes to the
|
|
895
|
+
* operator. It is returned whenever the broker is not switched on, and whenever
|
|
896
|
+
* the installed `shieldcortex` build is older than the broker (a version skew
|
|
897
|
+
* this plugin has to survive, which is why every piece is looked up by name
|
|
898
|
+
* rather than imported).
|
|
899
|
+
*
|
|
900
|
+
* All four pieces are required together. A half-wired broker — a decision core
|
|
901
|
+
* with no config normaliser, say — would be a policy consuming unvalidated
|
|
902
|
+
* input, which is the one shape this feature must never take.
|
|
903
|
+
*/
|
|
904
|
+
function resolveBrokerRuntime(defenceMod, rawBrokerConfig, api) {
|
|
905
|
+
try {
|
|
906
|
+
const needed = ['normaliseBrokerConfig', 'brokerDecision', 'runJudge', 'timeoutOutcome'];
|
|
907
|
+
if (needed.some((fn) => typeof defenceMod?.[fn] !== 'function')) {
|
|
908
|
+
if (rawBrokerConfig?.enabled === true) {
|
|
909
|
+
api.logger?.warn?.('[shieldcortex] approval broker requested but this shieldcortex build does not provide it — every dangerous action still goes to you');
|
|
910
|
+
}
|
|
911
|
+
return undefined;
|
|
912
|
+
}
|
|
913
|
+
const config = defenceMod.normaliseBrokerConfig(rawBrokerConfig);
|
|
914
|
+
if (!config?.enabled)
|
|
915
|
+
return undefined;
|
|
916
|
+
api.logger?.info?.(`[shieldcortex] approval broker ON — judge via the gateway model pool${config.model ? ` (${config.model})` : ''}, pre-clear ${config.allowPreClear ? `at ≥${config.preClearConfidence}` : 'disabled'}`);
|
|
917
|
+
return {
|
|
918
|
+
config,
|
|
919
|
+
runJudge: defenceMod.runJudge,
|
|
920
|
+
brokerDecision: defenceMod.brokerDecision,
|
|
921
|
+
timeoutOutcome: defenceMod.timeoutOutcome,
|
|
922
|
+
approvalTimeoutMs: typeof defenceMod.approvalTimeoutMs === 'function' ? defenceMod.approvalTimeoutMs : undefined,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
catch (err) {
|
|
926
|
+
api.logger?.warn?.(`[shieldcortex] approval broker unavailable: ${err instanceof Error ? err.message : err} — holding every dangerous action for you`);
|
|
927
|
+
return undefined;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
export const __testables = { resolveBrokerRuntime };
|
|
851
931
|
export default {
|
|
852
932
|
id: PLUGIN_ID,
|
|
853
933
|
name: "ShieldCortex Real-time Scanner",
|
|
@@ -905,6 +985,7 @@ export default {
|
|
|
905
985
|
evaluateToolCall: typeof defenceMod.evaluateToolCall === 'function'
|
|
906
986
|
? defenceMod.evaluateToolCall
|
|
907
987
|
: undefined,
|
|
988
|
+
broker: resolveBrokerRuntime(defenceMod, interceptorConfig.actionGuard?.broker, api),
|
|
908
989
|
onAuditEntry: (entry) => syncInterceptEvent(entry, {
|
|
909
990
|
cloudApiKey: scConfig.cloudApiKey ?? '',
|
|
910
991
|
cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
|
package/dist/interceptor.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { mkdirSync, appendFileSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { join, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
+
import { createGatewayInvoker } from './broker-invoker.js';
|
|
5
6
|
const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'];
|
|
6
7
|
const CONTENT_FIELDS = {
|
|
7
8
|
remember: ['content', 'title'],
|
|
@@ -376,6 +377,41 @@ export function createScriptSourceResolver(cwd) {
|
|
|
376
377
|
}
|
|
377
378
|
};
|
|
378
379
|
}
|
|
380
|
+
/** Raised when the operator never answered the approval card (#143). Distinct
|
|
381
|
+
* from a transport error, because the two have opposite handling: an error
|
|
382
|
+
* routes to failurePolicy, a timeout routes to the broker's asymmetric rule. */
|
|
383
|
+
export class ApprovalTimeout extends Error {
|
|
384
|
+
timeoutMs;
|
|
385
|
+
constructor(timeoutMs) {
|
|
386
|
+
super(`no approval answer within ${timeoutMs}ms`);
|
|
387
|
+
this.timeoutMs = timeoutMs;
|
|
388
|
+
this.name = 'ApprovalTimeout';
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Race an approval against a deadline.
|
|
393
|
+
*
|
|
394
|
+
* `timeoutMs <= 0` means no deadline at all — the pre-#143 behaviour, where the
|
|
395
|
+
* gateway's own card owns the waiting. A deadline is only ever applied when the
|
|
396
|
+
* broker is in play, and it can only turn an unanswered card into the broker's
|
|
397
|
+
* timeout rule, which for everything but a pre-cleared call is a denial.
|
|
398
|
+
*/
|
|
399
|
+
export function withApprovalDeadline(approval, timeoutMs) {
|
|
400
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
401
|
+
return approval;
|
|
402
|
+
let timer;
|
|
403
|
+
// A card answered after we stopped listening must not surface as an unhandled
|
|
404
|
+
// rejection in the gateway process.
|
|
405
|
+
const guarded = approval.catch(err => { throw err; });
|
|
406
|
+
guarded.catch(() => { });
|
|
407
|
+
return Promise.race([
|
|
408
|
+
guarded,
|
|
409
|
+
new Promise((_resolve, reject) => {
|
|
410
|
+
timer = setTimeout(() => reject(new ApprovalTimeout(timeoutMs)), timeoutMs);
|
|
411
|
+
}),
|
|
412
|
+
]).finally(() => { if (timer)
|
|
413
|
+
clearTimeout(timer); });
|
|
414
|
+
}
|
|
379
415
|
/** The cwd a tool call runs in, if the gateway or the call itself names one. */
|
|
380
416
|
function toolCallCwd(context) {
|
|
381
417
|
if (typeof context.cwd === 'string' && context.cwd)
|
|
@@ -387,6 +423,13 @@ function toolCallCwd(context) {
|
|
|
387
423
|
}
|
|
388
424
|
return undefined;
|
|
389
425
|
}
|
|
426
|
+
/** How many recent tool NAMES the judge is told about. Names only, never
|
|
427
|
+
* arguments — see buildSessionSummary. */
|
|
428
|
+
const SESSION_TOOL_MEMORY = 12;
|
|
429
|
+
/** Tool names are registry-supplied, not free text, but an MCP server can name
|
|
430
|
+
* a tool anything at all — so they are reduced to an identifier shape before
|
|
431
|
+
* being placed anywhere near a prompt. */
|
|
432
|
+
const TOOL_NAME_SAFE = /[^A-Za-z0-9_.:-]/g;
|
|
390
433
|
export function createInterceptor(config, pipeline, options) {
|
|
391
434
|
const denyCache = new DenyCache();
|
|
392
435
|
const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
|
|
@@ -394,6 +437,13 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
394
437
|
const onAuditEntry = options?.onAuditEntry;
|
|
395
438
|
const actionGuardCfg = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
|
|
396
439
|
const evaluateToolCall = options?.evaluateToolCall;
|
|
440
|
+
const broker = options?.broker;
|
|
441
|
+
// The judge rides the operator's own model pool, so its calls are their cost
|
|
442
|
+
// and their rate limit. Bounded per minute, and running out means "no judge",
|
|
443
|
+
// which the decision core reads as "hold for the human" — never as an allow.
|
|
444
|
+
const judgeLimiter = new RateLimiter(options?.maxJudgeCallsPerMinute ?? 20);
|
|
445
|
+
/** Bare tool names seen this session, newest last. See buildSessionSummary. */
|
|
446
|
+
const recentTools = [];
|
|
397
447
|
function emitAudit(entry) {
|
|
398
448
|
writeAuditEntry(entry);
|
|
399
449
|
onAuditEntry?.(entry);
|
|
@@ -408,6 +458,97 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
408
458
|
preview: preview.slice(0, 200), ts: new Date().toISOString(),
|
|
409
459
|
};
|
|
410
460
|
}
|
|
461
|
+
// ── Approval broker (#143) ────────────────────────────────────────────────
|
|
462
|
+
/**
|
|
463
|
+
* The ONLY thing the judge is told about the session.
|
|
464
|
+
*
|
|
465
|
+
* The design's third open question was "what does the broker see of the
|
|
466
|
+
* session, and how do we stop *that* being the injection vector?" — a
|
|
467
|
+
* poisoned transcript arguing its own approval is the obvious attack. The
|
|
468
|
+
* answer here is the narrowest thing that still means anything: a list of
|
|
469
|
+
* bare tool NAMES, sanitised to an identifier shape. No arguments, no
|
|
470
|
+
* content, no memory text, no user or assistant turns.
|
|
471
|
+
*
|
|
472
|
+
* That is enough for "does this action fit what the session was doing?" —
|
|
473
|
+
* an `npm install` in a session of Read/Edit/Bash is in pattern; the same
|
|
474
|
+
* command as the first act of a session is not — and it carries no attacker
|
|
475
|
+
* prose, because there is nowhere in it for prose to live.
|
|
476
|
+
*/
|
|
477
|
+
function buildSessionSummary() {
|
|
478
|
+
if (recentTools.length === 0)
|
|
479
|
+
return undefined;
|
|
480
|
+
const names = [...new Set(recentTools)].join(', ');
|
|
481
|
+
return `tools used in this session so far (names only, no arguments): ${names}`;
|
|
482
|
+
}
|
|
483
|
+
function noteToolForSession(toolName) {
|
|
484
|
+
const safe = String(toolName ?? '').replace(TOOL_NAME_SAFE, '').slice(0, 60);
|
|
485
|
+
if (!safe)
|
|
486
|
+
return;
|
|
487
|
+
recentTools.push(safe);
|
|
488
|
+
if (recentTools.length > SESSION_TOOL_MEMORY)
|
|
489
|
+
recentTools.shift();
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* One broker pass over a dangerous-tier verdict.
|
|
493
|
+
*
|
|
494
|
+
* Returns null when the broker is not in play at all — no runtime injected,
|
|
495
|
+
* or disabled by config — and the caller then behaves exactly as it did
|
|
496
|
+
* before #143. Every *failure* inside a pass (no model seam, pool down, junk
|
|
497
|
+
* reply, budget spent, core throwing) resolves to a decision of `hold` or to
|
|
498
|
+
* null, both of which route to the operator. There is no path here that
|
|
499
|
+
* produces an allow the guard would not otherwise have produced.
|
|
500
|
+
*/
|
|
501
|
+
async function runBroker(context, v) {
|
|
502
|
+
if (!broker || broker.config?.enabled !== true)
|
|
503
|
+
return null;
|
|
504
|
+
try {
|
|
505
|
+
// No seam on this gateway build → no invoker → no judge. Not an error:
|
|
506
|
+
// it is the honest state of every gateway shipping today.
|
|
507
|
+
const invoke = createGatewayInvoker(context, {
|
|
508
|
+
model: broker.config.model,
|
|
509
|
+
timeoutMs: broker.config.judgeTimeoutMs,
|
|
510
|
+
});
|
|
511
|
+
let judge = null;
|
|
512
|
+
if (invoke && judgeLimiter.shouldAllow()) {
|
|
513
|
+
judge = await broker.runJudge({
|
|
514
|
+
tool: context.toolName,
|
|
515
|
+
toolInput: context.arguments,
|
|
516
|
+
verdict: { severity: v.severity, action: v.action, reason: v.reason, signals: v.signals },
|
|
517
|
+
sessionSummary: buildSessionSummary(),
|
|
518
|
+
}, invoke, { timeoutMs: broker.config.judgeTimeoutMs });
|
|
519
|
+
}
|
|
520
|
+
else if (invoke) {
|
|
521
|
+
log.warn(`[shieldcortex] approval broker: judge budget spent this minute — holding ${context.toolName} for the operator`);
|
|
522
|
+
}
|
|
523
|
+
const decision = broker.brokerDecision({
|
|
524
|
+
tool: context.toolName,
|
|
525
|
+
toolInput: context.arguments,
|
|
526
|
+
verdict: v,
|
|
527
|
+
judge,
|
|
528
|
+
policy: {
|
|
529
|
+
allowPreClear: broker.config.allowPreClear,
|
|
530
|
+
preClearConfidence: broker.config.preClearConfidence,
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
// A decision we cannot read is not a decision. Falling back to null puts
|
|
534
|
+
// the call on the pre-#143 path, which asks the human.
|
|
535
|
+
if (!decision || typeof decision.outcome !== 'string')
|
|
536
|
+
return null;
|
|
537
|
+
return decision;
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
log.warn(`[shieldcortex] ⚠️ approval broker error: ${err instanceof Error ? err.message : err} — holding for the operator`);
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/** How long to wait for a human once the broker is in play. */
|
|
545
|
+
function brokerApprovalTimeoutMs(severity) {
|
|
546
|
+
if (!broker)
|
|
547
|
+
return 0;
|
|
548
|
+
if (typeof broker.approvalTimeoutMs === 'function')
|
|
549
|
+
return broker.approvalTimeoutMs(broker.config, severity);
|
|
550
|
+
return Math.min(broker.config.approvalTimeoutMs.sensitive, broker.config.approvalTimeoutMs.dangerous);
|
|
551
|
+
}
|
|
411
552
|
// WS2 fail-closed path (issue #59): when the real guard was never wired in or
|
|
412
553
|
// throws, run the dependency-free fallback scan. Three tiers, so no dangerous
|
|
413
554
|
// op is ever silently allowed on a scan failure — and every could-not-scan
|
|
@@ -528,44 +669,93 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
528
669
|
emitAudit({ ...base, action: 'warn', outcome: 'warned' });
|
|
529
670
|
return;
|
|
530
671
|
}
|
|
672
|
+
// ── AI-assisted approval broker (#143) ──────────────────────────────────
|
|
673
|
+
// Sits between the guard's verdict and the human, and can only move the
|
|
674
|
+
// answer toward caution. Off by default; when off, `brokered` is null and
|
|
675
|
+
// everything below is the pre-#143 code path unchanged.
|
|
676
|
+
const brokered = await runBroker(context, v);
|
|
677
|
+
// Every downstream audit row for a brokered call carries the broker's own
|
|
678
|
+
// record alongside the guard's, so one row answers "was a model consulted,
|
|
679
|
+
// what did it say, and what did that change?".
|
|
680
|
+
const auditBase = brokered ? { ...base, broker: brokered.audit } : base;
|
|
681
|
+
if (brokered?.outcome === 'harden') {
|
|
682
|
+
// The judge found something the rules did not. Deny outright rather than
|
|
683
|
+
// offer the operator a button to be socially-engineered into tapping.
|
|
684
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'auto_denied' });
|
|
685
|
+
log.warn(`[shieldcortex] approval broker HARDENED ${context.toolName} to a denial: ${brokered.reason}`);
|
|
686
|
+
throw new Error(`ShieldCortex: tool call blocked — ${brokered.reason}`);
|
|
687
|
+
}
|
|
688
|
+
if (brokered?.outcome === 'pre_clear') {
|
|
689
|
+
// Reversible, on-host, in-context, judge-confident: proceed without
|
|
690
|
+
// waiting. Loud on purpose — a release nobody approved must never be a
|
|
691
|
+
// silent one, because the audit row is the only thing that will ever tell
|
|
692
|
+
// the operator it happened.
|
|
693
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'approved' });
|
|
694
|
+
log.warn(`[shieldcortex] approval broker PRE-CLEARED ${context.toolName} without waiting for the operator: ${brokered.reason} [${v.signals.join(', ')}]`);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
531
697
|
if (typeof context.requireApproval !== 'function') {
|
|
532
698
|
// Unattended (no approver, e.g. cron/heartbeat): fail closed on the failure
|
|
533
699
|
// policy. High-severity dangerous defaults to deny — surfaced loudly to the
|
|
534
700
|
// gateway log so an operator sees it, because a silent no-op is exactly the
|
|
535
701
|
// failure mode we are eliminating.
|
|
702
|
+
//
|
|
703
|
+
// With the broker in play this can only get STRICTER: no approver is the
|
|
704
|
+
// timeout case by definition, and `timeoutOutcome` answers 'approve' only
|
|
705
|
+
// for a pre-cleared call, which already returned above. So a brokered call
|
|
706
|
+
// that reaches here denies even where failurePolicy would have allowed.
|
|
536
707
|
const failAction = config.failurePolicy[severity];
|
|
537
|
-
|
|
538
|
-
|
|
708
|
+
const brokerDenies = brokered ? broker.timeoutOutcome(brokered) === 'deny' : false;
|
|
709
|
+
const deny = failAction === 'deny' || brokerDenies;
|
|
710
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: deny ? 'failure_denied' : 'failure_allowed' });
|
|
711
|
+
if (deny) {
|
|
539
712
|
log.warn(`[shieldcortex] action-guard DENIED (unattended, no approver) ${context.toolName}: ${v.reason} [${v.signals.join(", ")}]`);
|
|
540
713
|
throw new Error(`ShieldCortex: tool call blocked — ${v.reason} (no approver, failure policy: deny)`);
|
|
541
714
|
}
|
|
542
715
|
return;
|
|
543
716
|
}
|
|
544
717
|
if (!rateLimiter.shouldAllow()) {
|
|
545
|
-
emitAudit({ ...
|
|
718
|
+
emitAudit({ ...auditBase, action: 'rate_limit', outcome: 'auto_denied' });
|
|
546
719
|
throw new Error('ShieldCortex: tool call auto-denied (approval rate limit exceeded)');
|
|
547
720
|
}
|
|
548
721
|
let approved;
|
|
549
722
|
try {
|
|
550
|
-
approved = await context.requireApproval(formatActionGuardPrompt(context.toolName, v));
|
|
723
|
+
approved = await withApprovalDeadline(context.requireApproval(formatActionGuardPrompt(context.toolName, v)), brokered ? brokerApprovalTimeoutMs(v.severity) : 0);
|
|
551
724
|
}
|
|
552
725
|
catch (err) {
|
|
726
|
+
if (brokered && err instanceof ApprovalTimeout) {
|
|
727
|
+
// The asymmetric path. Silence is only ever a yes for something the
|
|
728
|
+
// broker already pre-cleared — and that returned long before here — so
|
|
729
|
+
// in practice this is always a deny. It reads the broker's own derived
|
|
730
|
+
// flag rather than re-deriving its own idea of what is safe.
|
|
731
|
+
const outcome = broker.timeoutOutcome(brokered);
|
|
732
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: outcome === 'approve' ? 'approved' : 'auto_denied' });
|
|
733
|
+
if (outcome === 'approve') {
|
|
734
|
+
log.warn(`[shieldcortex] approval broker: no answer in ${err.timeoutMs}ms — auto-approving pre-cleared ${context.toolName}`);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
log.warn(`[shieldcortex] approval broker: no answer in ${err.timeoutMs}ms — DENYING ${context.toolName} (fail-closed)`);
|
|
738
|
+
throw new Error(`ShieldCortex: tool call blocked — no answer from the operator within ${err.timeoutMs}ms (fail-closed)`);
|
|
739
|
+
}
|
|
553
740
|
const failAction = config.failurePolicy[severity];
|
|
554
741
|
log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
|
|
555
|
-
emitAudit({ ...
|
|
742
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
|
|
556
743
|
if (failAction === 'deny') {
|
|
557
744
|
throw new Error('ShieldCortex: tool call blocked — approval error, failure policy: deny');
|
|
558
745
|
}
|
|
559
746
|
return;
|
|
560
747
|
}
|
|
561
748
|
if (approved) {
|
|
562
|
-
emitAudit({ ...
|
|
749
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'approved' });
|
|
563
750
|
return;
|
|
564
751
|
}
|
|
565
|
-
emitAudit({ ...
|
|
752
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'denied' });
|
|
566
753
|
throw new Error('ShieldCortex: tool call denied by user');
|
|
567
754
|
}
|
|
568
755
|
async function handleToolCall(context) {
|
|
756
|
+
// Remember the NAME only. This is the entirety of what the approval broker's
|
|
757
|
+
// judge will ever learn about the session — see buildSessionSummary.
|
|
758
|
+
noteToolForSession(context.toolName);
|
|
569
759
|
// Non-memory tools go through the Action Guard (what the agent DOES); the
|
|
570
760
|
// memory-write tools continue through the content defence pipeline below.
|
|
571
761
|
if (!WATCHED_TOOLS.includes(context.toolName)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.47.
|
|
3
|
+
"version": "4.47.19",
|
|
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
|
@@ -16,7 +16,7 @@ import { homedir } from "node:os";
|
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
17
|
|
|
18
18
|
import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
|
|
19
|
-
import type { InterceptorConfig } from './interceptor.js';
|
|
19
|
+
import type { InterceptorConfig, BrokerRuntime } from './interceptor.js';
|
|
20
20
|
import { syncInterceptEvent } from './intercept-ingest.js';
|
|
21
21
|
import { cloudSync } from './cloud-sync.js';
|
|
22
22
|
|
|
@@ -242,6 +242,11 @@ interface InterceptorUserConfig {
|
|
|
242
242
|
enforce?: boolean;
|
|
243
243
|
autoApprove?: string[];
|
|
244
244
|
auditAllows?: boolean;
|
|
245
|
+
/** AI-assisted approval broker (#143). Passed through RAW: the real
|
|
246
|
+
* validation is `normaliseBrokerConfig` in the main package, which is the
|
|
247
|
+
* single place that knows which values would loosen an invariant. Off
|
|
248
|
+
* unless `enabled: true`. */
|
|
249
|
+
broker?: Record<string, unknown>;
|
|
245
250
|
};
|
|
246
251
|
}
|
|
247
252
|
|
|
@@ -319,6 +324,16 @@ const PLUGIN_CONFIG_UI_HINTS = {
|
|
|
319
324
|
help: "Write an audit entry when the guard evaluates a recognised operation and allows it.",
|
|
320
325
|
advanced: true,
|
|
321
326
|
},
|
|
327
|
+
"interceptor.actionGuard.broker.enabled": {
|
|
328
|
+
label: "AI Approval Broker",
|
|
329
|
+
help: "Let a fast model judge dangerous-tier approvals before they reach you: it can deny outright when it sees injection, and release reversible, in-context, high-confidence actions without waiting. Never applies to catastrophic operations. Off by default.",
|
|
330
|
+
advanced: true,
|
|
331
|
+
},
|
|
332
|
+
"interceptor.actionGuard.broker.allowPreClear": {
|
|
333
|
+
label: "Allow Broker Pre-clear",
|
|
334
|
+
help: "Off = every dangerous-tier action still waits for you; the broker can then only harden, never release.",
|
|
335
|
+
advanced: true,
|
|
336
|
+
},
|
|
322
337
|
} as const;
|
|
323
338
|
|
|
324
339
|
const SEVERITY_ACTION_SCHEMA = {
|
|
@@ -352,6 +367,28 @@ const INTERCEPTOR_JSON_SCHEMA = {
|
|
|
352
367
|
enforce: { type: "boolean" },
|
|
353
368
|
autoApprove: { type: "array", items: { type: "string" } },
|
|
354
369
|
auditAllows: { type: "boolean" },
|
|
370
|
+
// #143. Mirrors normaliseBrokerConfig's allowlist; that function still
|
|
371
|
+
// has the last word, so a value that slips past the schema is still
|
|
372
|
+
// range-checked (and dropped) before the broker sees it.
|
|
373
|
+
broker: {
|
|
374
|
+
type: "object",
|
|
375
|
+
additionalProperties: false,
|
|
376
|
+
properties: {
|
|
377
|
+
enabled: { type: "boolean" },
|
|
378
|
+
allowPreClear: { type: "boolean" },
|
|
379
|
+
preClearConfidence: { type: "number", minimum: 0.9, maximum: 1 },
|
|
380
|
+
judgeTimeoutMs: { type: "number", minimum: 500, maximum: 60000 },
|
|
381
|
+
approvalTimeoutMs: {
|
|
382
|
+
type: "object",
|
|
383
|
+
additionalProperties: false,
|
|
384
|
+
properties: {
|
|
385
|
+
sensitive: { type: "number", minimum: 1000, maximum: 3600000 },
|
|
386
|
+
dangerous: { type: "number", minimum: 1000, maximum: 3600000 },
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
model: { type: "string" },
|
|
390
|
+
},
|
|
391
|
+
},
|
|
355
392
|
},
|
|
356
393
|
},
|
|
357
394
|
},
|
|
@@ -491,6 +528,12 @@ function normaliseInterceptorConfig(raw: unknown): InterceptorUserConfig | undef
|
|
|
491
528
|
if (Array.isArray(rawGuard.autoApprove) && rawGuard.autoApprove.every((entry) => typeof entry === "string")) {
|
|
492
529
|
guard.autoApprove = rawGuard.autoApprove as string[];
|
|
493
530
|
}
|
|
531
|
+
// Carried through untouched — normaliseBrokerConfig is the boundary, and
|
|
532
|
+
// splitting that job across two files is how one of the halves ends up
|
|
533
|
+
// being the lenient one.
|
|
534
|
+
if (rawGuard.broker && typeof rawGuard.broker === "object" && !Array.isArray(rawGuard.broker)) {
|
|
535
|
+
guard.broker = rawGuard.broker as Record<string, unknown>;
|
|
536
|
+
}
|
|
494
537
|
if (Object.keys(guard).length > 0) out.actionGuard = guard;
|
|
495
538
|
}
|
|
496
539
|
|
|
@@ -1020,6 +1063,55 @@ async function handleTypedBeforeToolCall(
|
|
|
1020
1063
|
|
|
1021
1064
|
// ==================== PLUGIN EXPORT ====================
|
|
1022
1065
|
|
|
1066
|
+
/**
|
|
1067
|
+
* Assemble the approval broker (#143) from the main package, or return
|
|
1068
|
+
* undefined.
|
|
1069
|
+
*
|
|
1070
|
+
* Undefined is the normal answer and a safe one: no broker means the guard
|
|
1071
|
+
* behaves exactly as it did before #143 — every dangerous-tier call goes to the
|
|
1072
|
+
* operator. It is returned whenever the broker is not switched on, and whenever
|
|
1073
|
+
* the installed `shieldcortex` build is older than the broker (a version skew
|
|
1074
|
+
* this plugin has to survive, which is why every piece is looked up by name
|
|
1075
|
+
* rather than imported).
|
|
1076
|
+
*
|
|
1077
|
+
* All four pieces are required together. A half-wired broker — a decision core
|
|
1078
|
+
* with no config normaliser, say — would be a policy consuming unvalidated
|
|
1079
|
+
* input, which is the one shape this feature must never take.
|
|
1080
|
+
*/
|
|
1081
|
+
function resolveBrokerRuntime(
|
|
1082
|
+
defenceMod: any,
|
|
1083
|
+
rawBrokerConfig: Record<string, unknown> | undefined,
|
|
1084
|
+
api: PluginApi,
|
|
1085
|
+
): BrokerRuntime | undefined {
|
|
1086
|
+
try {
|
|
1087
|
+
const needed = ['normaliseBrokerConfig', 'brokerDecision', 'runJudge', 'timeoutOutcome'];
|
|
1088
|
+
if (needed.some((fn) => typeof defenceMod?.[fn] !== 'function')) {
|
|
1089
|
+
if (rawBrokerConfig?.enabled === true) {
|
|
1090
|
+
(api.logger as any)?.warn?.('[shieldcortex] approval broker requested but this shieldcortex build does not provide it — every dangerous action still goes to you');
|
|
1091
|
+
}
|
|
1092
|
+
return undefined;
|
|
1093
|
+
}
|
|
1094
|
+
const config = defenceMod.normaliseBrokerConfig(rawBrokerConfig);
|
|
1095
|
+
if (!config?.enabled) return undefined;
|
|
1096
|
+
|
|
1097
|
+
api.logger?.info?.(
|
|
1098
|
+
`[shieldcortex] approval broker ON — judge via the gateway model pool${config.model ? ` (${config.model})` : ''}, pre-clear ${config.allowPreClear ? `at ≥${config.preClearConfidence}` : 'disabled'}`,
|
|
1099
|
+
);
|
|
1100
|
+
return {
|
|
1101
|
+
config,
|
|
1102
|
+
runJudge: defenceMod.runJudge,
|
|
1103
|
+
brokerDecision: defenceMod.brokerDecision,
|
|
1104
|
+
timeoutOutcome: defenceMod.timeoutOutcome,
|
|
1105
|
+
approvalTimeoutMs: typeof defenceMod.approvalTimeoutMs === 'function' ? defenceMod.approvalTimeoutMs : undefined,
|
|
1106
|
+
} as BrokerRuntime;
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
(api.logger as any)?.warn?.(`[shieldcortex] approval broker unavailable: ${err instanceof Error ? err.message : err} — holding every dangerous action for you`);
|
|
1109
|
+
return undefined;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
export const __testables = { resolveBrokerRuntime };
|
|
1114
|
+
|
|
1023
1115
|
export default {
|
|
1024
1116
|
id: PLUGIN_ID,
|
|
1025
1117
|
name: "ShieldCortex Real-time Scanner",
|
|
@@ -1033,6 +1125,7 @@ export default {
|
|
|
1033
1125
|
jsonSchema: PLUGIN_CONFIG_JSON_SCHEMA,
|
|
1034
1126
|
},
|
|
1035
1127
|
|
|
1128
|
+
|
|
1036
1129
|
register(api: PluginApi) {
|
|
1037
1130
|
if (_registered) return;
|
|
1038
1131
|
_registered = true;
|
|
@@ -1080,6 +1173,7 @@ export default {
|
|
|
1080
1173
|
evaluateToolCall: typeof (defenceMod as any).evaluateToolCall === 'function'
|
|
1081
1174
|
? ((defenceMod as any).evaluateToolCall as Parameters<typeof createInterceptor>[2] extends { evaluateToolCall?: infer E } ? E : never)
|
|
1082
1175
|
: undefined,
|
|
1176
|
+
broker: resolveBrokerRuntime(defenceMod, interceptorConfig.actionGuard?.broker, api),
|
|
1083
1177
|
onAuditEntry: (entry) => syncInterceptEvent(entry, {
|
|
1084
1178
|
cloudApiKey: (scConfig as any).cloudApiKey ?? '',
|
|
1085
1179
|
cloudBaseUrl: (scConfig as any).cloudBaseUrl ?? 'https://api.shieldcortex.ai',
|
package/interceptor.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { mkdirSync, appendFileSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { join, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
+
import { createGatewayInvoker, type BrokerInvokerContext, type ModelInvokerLike } from './broker-invoker.js';
|
|
5
6
|
|
|
6
7
|
export type Severity = 'low' | 'medium' | 'high' | 'critical';
|
|
7
8
|
export type InterceptAction = 'log' | 'warn' | 'require_approval';
|
|
@@ -29,6 +30,11 @@ export interface ActionGuardConfig {
|
|
|
29
30
|
/** Audit recognised (severity 'sensitive'+) allow-decisions. Default true (issue #95).
|
|
30
31
|
* Benign allows are never audited — on a busy agent every `ls` would drown the stream. */
|
|
31
32
|
auditAllows?: boolean;
|
|
33
|
+
/** RAW approval-broker config (#143), passed through untouched. It is
|
|
34
|
+
* normalised by `normaliseBrokerConfig` in the main package before it reaches
|
|
35
|
+
* the broker — this plugin never interprets it, so a hostile value cannot be
|
|
36
|
+
* laundered by travelling through here. Absent/disabled = today's behaviour. */
|
|
37
|
+
broker?: Record<string, unknown>;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
/** Structural shape of a Tool Action Guard verdict (kept local to avoid a
|
|
@@ -56,6 +62,77 @@ export type ToolGuardEvaluator = (
|
|
|
56
62
|
options?: ToolGuardEvaluatorOptions,
|
|
57
63
|
) => ToolGuardVerdictLike;
|
|
58
64
|
|
|
65
|
+
// ── Approval broker (#143) ──────────────────────────────────────────────────
|
|
66
|
+
// Structurally typed, like ToolGuardVerdictLike above: the real implementations
|
|
67
|
+
// live in `shieldcortex/defence` (approval-broker.ts, approval-judge.ts,
|
|
68
|
+
// broker-config.ts) and are injected at runtime, because this plugin is built
|
|
69
|
+
// across a package boundary and must keep working when the main package is a
|
|
70
|
+
// different version — or absent. No injection = no broker = today's behaviour.
|
|
71
|
+
|
|
72
|
+
export interface JudgeResultLike {
|
|
73
|
+
assessment: 'benign' | 'uncertain' | 'malicious';
|
|
74
|
+
confidence: number;
|
|
75
|
+
inContext: boolean;
|
|
76
|
+
injectionSuspected: boolean;
|
|
77
|
+
rationale?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface BrokerAuditLike {
|
|
81
|
+
outcome: string;
|
|
82
|
+
tool: string;
|
|
83
|
+
action: string;
|
|
84
|
+
severity: string;
|
|
85
|
+
signals: string[];
|
|
86
|
+
judgeAssessment: string;
|
|
87
|
+
judgeConfidence: number | null;
|
|
88
|
+
injectionSuspected: boolean;
|
|
89
|
+
inContext: boolean | null;
|
|
90
|
+
reason: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface BrokerDecisionLike {
|
|
94
|
+
outcome: 'not_brokerable' | 'harden' | 'hold' | 'pre_clear';
|
|
95
|
+
reason: string;
|
|
96
|
+
canAutoApproveOnTimeout: boolean;
|
|
97
|
+
audit: BrokerAuditLike;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface BrokerConfigLike {
|
|
101
|
+
enabled: boolean;
|
|
102
|
+
allowPreClear: boolean;
|
|
103
|
+
preClearConfidence: number;
|
|
104
|
+
judgeTimeoutMs: number;
|
|
105
|
+
approvalTimeoutMs: { sensitive: number; dangerous: number };
|
|
106
|
+
model?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Everything the interceptor needs to run a broker pass, injected as one unit
|
|
110
|
+
* so a half-wired broker (a decision core with no judge, say) cannot exist. */
|
|
111
|
+
export interface BrokerRuntime {
|
|
112
|
+
/** ALREADY normalised by `normaliseBrokerConfig`. The interceptor does not
|
|
113
|
+
* sanitise config; it consumes a config that was sanitised at the boundary. */
|
|
114
|
+
config: BrokerConfigLike;
|
|
115
|
+
runJudge: (
|
|
116
|
+
req: {
|
|
117
|
+
tool: string;
|
|
118
|
+
toolInput: unknown;
|
|
119
|
+
verdict: { severity: string; action: string; reason: string; signals: string[] };
|
|
120
|
+
sessionSummary?: string;
|
|
121
|
+
},
|
|
122
|
+
invoke: ModelInvokerLike,
|
|
123
|
+
opts?: { timeoutMs?: number },
|
|
124
|
+
) => Promise<JudgeResultLike | null>;
|
|
125
|
+
brokerDecision: (input: {
|
|
126
|
+
tool: string;
|
|
127
|
+
toolInput: unknown;
|
|
128
|
+
verdict: ToolGuardVerdictLike;
|
|
129
|
+
judge: JudgeResultLike | null;
|
|
130
|
+
policy?: { allowPreClear: boolean; preClearConfidence: number };
|
|
131
|
+
}) => BrokerDecisionLike;
|
|
132
|
+
timeoutOutcome: (decision: BrokerDecisionLike) => 'approve' | 'deny';
|
|
133
|
+
approvalTimeoutMs?: (config: BrokerConfigLike, severity: string) => number;
|
|
134
|
+
}
|
|
135
|
+
|
|
59
136
|
export interface InterceptorConfig {
|
|
60
137
|
enabled: boolean;
|
|
61
138
|
severityActions: Record<Severity, InterceptAction>;
|
|
@@ -68,6 +145,11 @@ export interface ToolCallContext {
|
|
|
68
145
|
toolName: string;
|
|
69
146
|
arguments: Record<string, unknown>;
|
|
70
147
|
requireApproval?: (message: string) => Promise<boolean>;
|
|
148
|
+
/** Optional one-shot completion through the gateway's OWN model pool (#143).
|
|
149
|
+
* ShieldCortex supplies no credentials of its own; when a gateway build does
|
|
150
|
+
* not offer this, the broker has no judge and holds for the operator —
|
|
151
|
+
* exactly today's behaviour. See broker-invoker.ts. */
|
|
152
|
+
invokeModel?: BrokerInvokerContext['invokeModel'];
|
|
71
153
|
/** Working directory the tool call runs in, when the gateway supplies one —
|
|
72
154
|
* used to resolve a relative script path (issue #4). Falls back to the
|
|
73
155
|
* call's own `cwd` argument, then `process.cwd()`. */
|
|
@@ -89,6 +171,10 @@ export interface InterceptAuditEntry {
|
|
|
89
171
|
outcome: 'approved' | 'denied' | 'auto_denied' | 'logged' | 'warned' | 'failure_allowed' | 'failure_denied' | 'allowed';
|
|
90
172
|
preview: string;
|
|
91
173
|
ts: string;
|
|
174
|
+
/** The approval broker's record for this call (#143). Present on exactly the
|
|
175
|
+
* calls the broker judged, so "was a model consulted, and what did it say?"
|
|
176
|
+
* is answerable from the audit stream alone. Absent = the broker never ran. */
|
|
177
|
+
broker?: BrokerAuditLike;
|
|
92
178
|
}
|
|
93
179
|
|
|
94
180
|
const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'] as const;
|
|
@@ -538,6 +624,39 @@ export function createScriptSourceResolver(cwd?: string): (scriptPath: string) =
|
|
|
538
624
|
};
|
|
539
625
|
}
|
|
540
626
|
|
|
627
|
+
/** Raised when the operator never answered the approval card (#143). Distinct
|
|
628
|
+
* from a transport error, because the two have opposite handling: an error
|
|
629
|
+
* routes to failurePolicy, a timeout routes to the broker's asymmetric rule. */
|
|
630
|
+
export class ApprovalTimeout extends Error {
|
|
631
|
+
constructor(public readonly timeoutMs: number) {
|
|
632
|
+
super(`no approval answer within ${timeoutMs}ms`);
|
|
633
|
+
this.name = 'ApprovalTimeout';
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Race an approval against a deadline.
|
|
639
|
+
*
|
|
640
|
+
* `timeoutMs <= 0` means no deadline at all — the pre-#143 behaviour, where the
|
|
641
|
+
* gateway's own card owns the waiting. A deadline is only ever applied when the
|
|
642
|
+
* broker is in play, and it can only turn an unanswered card into the broker's
|
|
643
|
+
* timeout rule, which for everything but a pre-cleared call is a denial.
|
|
644
|
+
*/
|
|
645
|
+
export function withApprovalDeadline(approval: Promise<boolean>, timeoutMs: number): Promise<boolean> {
|
|
646
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return approval;
|
|
647
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
648
|
+
// A card answered after we stopped listening must not surface as an unhandled
|
|
649
|
+
// rejection in the gateway process.
|
|
650
|
+
const guarded = approval.catch(err => { throw err; });
|
|
651
|
+
guarded.catch(() => { /* handled by the race below or deliberately dropped */ });
|
|
652
|
+
return Promise.race([
|
|
653
|
+
guarded,
|
|
654
|
+
new Promise<boolean>((_resolve, reject) => {
|
|
655
|
+
timer = setTimeout(() => reject(new ApprovalTimeout(timeoutMs)), timeoutMs);
|
|
656
|
+
}),
|
|
657
|
+
]).finally(() => { if (timer) clearTimeout(timer); });
|
|
658
|
+
}
|
|
659
|
+
|
|
541
660
|
/** The cwd a tool call runs in, if the gateway or the call itself names one. */
|
|
542
661
|
function toolCallCwd(context: ToolCallContext): string | undefined {
|
|
543
662
|
if (typeof context.cwd === 'string' && context.cwd) return context.cwd;
|
|
@@ -553,8 +672,24 @@ interface InterceptorOptions {
|
|
|
553
672
|
onAuditEntry?: (entry: InterceptAuditEntry) => void;
|
|
554
673
|
/** Tool Action Guard evaluator, injected from `shieldcortex/defence` at runtime. */
|
|
555
674
|
evaluateToolCall?: ToolGuardEvaluator;
|
|
675
|
+
/** Approval broker (#143), injected from `shieldcortex/defence` at runtime.
|
|
676
|
+
* Absent, or present with `config.enabled: false`, means no model is ever
|
|
677
|
+
* consulted and the guard behaves exactly as it did before #143. */
|
|
678
|
+
broker?: BrokerRuntime;
|
|
679
|
+
/** Judge calls allowed per minute. The judge spends the OPERATOR's own rate
|
|
680
|
+
* limit, so a looping or compromised agent must not be able to spend it
|
|
681
|
+
* without bound. Exhausting it yields no judge, which yields a hold. */
|
|
682
|
+
maxJudgeCallsPerMinute?: number;
|
|
556
683
|
}
|
|
557
684
|
|
|
685
|
+
/** How many recent tool NAMES the judge is told about. Names only, never
|
|
686
|
+
* arguments — see buildSessionSummary. */
|
|
687
|
+
const SESSION_TOOL_MEMORY = 12;
|
|
688
|
+
/** Tool names are registry-supplied, not free text, but an MCP server can name
|
|
689
|
+
* a tool anything at all — so they are reduced to an identifier shape before
|
|
690
|
+
* being placed anywhere near a prompt. */
|
|
691
|
+
const TOOL_NAME_SAFE = /[^A-Za-z0-9_.:-]/g;
|
|
692
|
+
|
|
558
693
|
export function createInterceptor(
|
|
559
694
|
config: InterceptorConfig,
|
|
560
695
|
pipeline: PipelineRunner,
|
|
@@ -569,6 +704,13 @@ export function createInterceptor(
|
|
|
569
704
|
const onAuditEntry = options?.onAuditEntry;
|
|
570
705
|
const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
|
|
571
706
|
const evaluateToolCall = options?.evaluateToolCall;
|
|
707
|
+
const broker = options?.broker;
|
|
708
|
+
// The judge rides the operator's own model pool, so its calls are their cost
|
|
709
|
+
// and their rate limit. Bounded per minute, and running out means "no judge",
|
|
710
|
+
// which the decision core reads as "hold for the human" — never as an allow.
|
|
711
|
+
const judgeLimiter = new RateLimiter(options?.maxJudgeCallsPerMinute ?? 20);
|
|
712
|
+
/** Bare tool names seen this session, newest last. See buildSessionSummary. */
|
|
713
|
+
const recentTools: string[] = [];
|
|
572
714
|
|
|
573
715
|
function emitAudit(entry: InterceptAuditEntry): void {
|
|
574
716
|
writeAuditEntry(entry);
|
|
@@ -586,6 +728,100 @@ export function createInterceptor(
|
|
|
586
728
|
};
|
|
587
729
|
}
|
|
588
730
|
|
|
731
|
+
// ── Approval broker (#143) ────────────────────────────────────────────────
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* The ONLY thing the judge is told about the session.
|
|
735
|
+
*
|
|
736
|
+
* The design's third open question was "what does the broker see of the
|
|
737
|
+
* session, and how do we stop *that* being the injection vector?" — a
|
|
738
|
+
* poisoned transcript arguing its own approval is the obvious attack. The
|
|
739
|
+
* answer here is the narrowest thing that still means anything: a list of
|
|
740
|
+
* bare tool NAMES, sanitised to an identifier shape. No arguments, no
|
|
741
|
+
* content, no memory text, no user or assistant turns.
|
|
742
|
+
*
|
|
743
|
+
* That is enough for "does this action fit what the session was doing?" —
|
|
744
|
+
* an `npm install` in a session of Read/Edit/Bash is in pattern; the same
|
|
745
|
+
* command as the first act of a session is not — and it carries no attacker
|
|
746
|
+
* prose, because there is nowhere in it for prose to live.
|
|
747
|
+
*/
|
|
748
|
+
function buildSessionSummary(): string | undefined {
|
|
749
|
+
if (recentTools.length === 0) return undefined;
|
|
750
|
+
const names = [...new Set(recentTools)].join(', ');
|
|
751
|
+
return `tools used in this session so far (names only, no arguments): ${names}`;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function noteToolForSession(toolName: string): void {
|
|
755
|
+
const safe = String(toolName ?? '').replace(TOOL_NAME_SAFE, '').slice(0, 60);
|
|
756
|
+
if (!safe) return;
|
|
757
|
+
recentTools.push(safe);
|
|
758
|
+
if (recentTools.length > SESSION_TOOL_MEMORY) recentTools.shift();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* One broker pass over a dangerous-tier verdict.
|
|
763
|
+
*
|
|
764
|
+
* Returns null when the broker is not in play at all — no runtime injected,
|
|
765
|
+
* or disabled by config — and the caller then behaves exactly as it did
|
|
766
|
+
* before #143. Every *failure* inside a pass (no model seam, pool down, junk
|
|
767
|
+
* reply, budget spent, core throwing) resolves to a decision of `hold` or to
|
|
768
|
+
* null, both of which route to the operator. There is no path here that
|
|
769
|
+
* produces an allow the guard would not otherwise have produced.
|
|
770
|
+
*/
|
|
771
|
+
async function runBroker(context: ToolCallContext, v: ToolGuardVerdictLike): Promise<BrokerDecisionLike | null> {
|
|
772
|
+
if (!broker || broker.config?.enabled !== true) return null;
|
|
773
|
+
|
|
774
|
+
try {
|
|
775
|
+
// No seam on this gateway build → no invoker → no judge. Not an error:
|
|
776
|
+
// it is the honest state of every gateway shipping today.
|
|
777
|
+
const invoke = createGatewayInvoker(context as BrokerInvokerContext, {
|
|
778
|
+
model: broker.config.model,
|
|
779
|
+
timeoutMs: broker.config.judgeTimeoutMs,
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
let judge: JudgeResultLike | null = null;
|
|
783
|
+
if (invoke && judgeLimiter.shouldAllow()) {
|
|
784
|
+
judge = await broker.runJudge(
|
|
785
|
+
{
|
|
786
|
+
tool: context.toolName,
|
|
787
|
+
toolInput: context.arguments,
|
|
788
|
+
verdict: { severity: v.severity, action: v.action, reason: v.reason, signals: v.signals },
|
|
789
|
+
sessionSummary: buildSessionSummary(),
|
|
790
|
+
},
|
|
791
|
+
invoke,
|
|
792
|
+
{ timeoutMs: broker.config.judgeTimeoutMs },
|
|
793
|
+
);
|
|
794
|
+
} else if (invoke) {
|
|
795
|
+
log.warn(`[shieldcortex] approval broker: judge budget spent this minute — holding ${context.toolName} for the operator`);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const decision = broker.brokerDecision({
|
|
799
|
+
tool: context.toolName,
|
|
800
|
+
toolInput: context.arguments,
|
|
801
|
+
verdict: v,
|
|
802
|
+
judge,
|
|
803
|
+
policy: {
|
|
804
|
+
allowPreClear: broker.config.allowPreClear,
|
|
805
|
+
preClearConfidence: broker.config.preClearConfidence,
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
// A decision we cannot read is not a decision. Falling back to null puts
|
|
809
|
+
// the call on the pre-#143 path, which asks the human.
|
|
810
|
+
if (!decision || typeof decision.outcome !== 'string') return null;
|
|
811
|
+
return decision;
|
|
812
|
+
} catch (err) {
|
|
813
|
+
log.warn(`[shieldcortex] ⚠️ approval broker error: ${err instanceof Error ? err.message : err} — holding for the operator`);
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** How long to wait for a human once the broker is in play. */
|
|
819
|
+
function brokerApprovalTimeoutMs(severity: string): number {
|
|
820
|
+
if (!broker) return 0;
|
|
821
|
+
if (typeof broker.approvalTimeoutMs === 'function') return broker.approvalTimeoutMs(broker.config, severity);
|
|
822
|
+
return Math.min(broker.config.approvalTimeoutMs.sensitive, broker.config.approvalTimeoutMs.dangerous);
|
|
823
|
+
}
|
|
824
|
+
|
|
589
825
|
// WS2 fail-closed path (issue #59): when the real guard was never wired in or
|
|
590
826
|
// throws, run the dependency-free fallback scan. Three tiers, so no dangerous
|
|
591
827
|
// op is ever silently allowed on a scan failure — and every could-not-scan
|
|
@@ -715,14 +951,49 @@ export function createInterceptor(
|
|
|
715
951
|
return;
|
|
716
952
|
}
|
|
717
953
|
|
|
954
|
+
// ── AI-assisted approval broker (#143) ──────────────────────────────────
|
|
955
|
+
// Sits between the guard's verdict and the human, and can only move the
|
|
956
|
+
// answer toward caution. Off by default; when off, `brokered` is null and
|
|
957
|
+
// everything below is the pre-#143 code path unchanged.
|
|
958
|
+
const brokered = await runBroker(context, v);
|
|
959
|
+
// Every downstream audit row for a brokered call carries the broker's own
|
|
960
|
+
// record alongside the guard's, so one row answers "was a model consulted,
|
|
961
|
+
// what did it say, and what did that change?".
|
|
962
|
+
const auditBase = brokered ? { ...base, broker: brokered.audit } : base;
|
|
963
|
+
|
|
964
|
+
if (brokered?.outcome === 'harden') {
|
|
965
|
+
// The judge found something the rules did not. Deny outright rather than
|
|
966
|
+
// offer the operator a button to be socially-engineered into tapping.
|
|
967
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'auto_denied' });
|
|
968
|
+
log.warn(`[shieldcortex] approval broker HARDENED ${context.toolName} to a denial: ${brokered.reason}`);
|
|
969
|
+
throw new Error(`ShieldCortex: tool call blocked — ${brokered.reason}`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (brokered?.outcome === 'pre_clear') {
|
|
973
|
+
// Reversible, on-host, in-context, judge-confident: proceed without
|
|
974
|
+
// waiting. Loud on purpose — a release nobody approved must never be a
|
|
975
|
+
// silent one, because the audit row is the only thing that will ever tell
|
|
976
|
+
// the operator it happened.
|
|
977
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'approved' });
|
|
978
|
+
log.warn(`[shieldcortex] approval broker PRE-CLEARED ${context.toolName} without waiting for the operator: ${brokered.reason} [${v.signals.join(', ')}]`);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
718
982
|
if (typeof context.requireApproval !== 'function') {
|
|
719
983
|
// Unattended (no approver, e.g. cron/heartbeat): fail closed on the failure
|
|
720
984
|
// policy. High-severity dangerous defaults to deny — surfaced loudly to the
|
|
721
985
|
// gateway log so an operator sees it, because a silent no-op is exactly the
|
|
722
986
|
// failure mode we are eliminating.
|
|
987
|
+
//
|
|
988
|
+
// With the broker in play this can only get STRICTER: no approver is the
|
|
989
|
+
// timeout case by definition, and `timeoutOutcome` answers 'approve' only
|
|
990
|
+
// for a pre-cleared call, which already returned above. So a brokered call
|
|
991
|
+
// that reaches here denies even where failurePolicy would have allowed.
|
|
723
992
|
const failAction = config.failurePolicy[severity];
|
|
724
|
-
|
|
725
|
-
|
|
993
|
+
const brokerDenies = brokered ? broker!.timeoutOutcome(brokered) === 'deny' : false;
|
|
994
|
+
const deny = failAction === 'deny' || brokerDenies;
|
|
995
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: deny ? 'failure_denied' : 'failure_allowed' });
|
|
996
|
+
if (deny) {
|
|
726
997
|
log.warn(`[shieldcortex] action-guard DENIED (unattended, no approver) ${context.toolName}: ${v.reason} [${v.signals.join(", ")}]`);
|
|
727
998
|
throw new Error(`ShieldCortex: tool call blocked — ${v.reason} (no approver, failure policy: deny)`);
|
|
728
999
|
}
|
|
@@ -730,17 +1001,34 @@ export function createInterceptor(
|
|
|
730
1001
|
}
|
|
731
1002
|
|
|
732
1003
|
if (!rateLimiter.shouldAllow()) {
|
|
733
|
-
emitAudit({ ...
|
|
1004
|
+
emitAudit({ ...auditBase, action: 'rate_limit', outcome: 'auto_denied' });
|
|
734
1005
|
throw new Error('ShieldCortex: tool call auto-denied (approval rate limit exceeded)');
|
|
735
1006
|
}
|
|
736
1007
|
|
|
737
1008
|
let approved: boolean;
|
|
738
1009
|
try {
|
|
739
|
-
approved = await
|
|
1010
|
+
approved = await withApprovalDeadline(
|
|
1011
|
+
context.requireApproval(formatActionGuardPrompt(context.toolName, v)),
|
|
1012
|
+
brokered ? brokerApprovalTimeoutMs(v.severity) : 0,
|
|
1013
|
+
);
|
|
740
1014
|
} catch (err) {
|
|
1015
|
+
if (brokered && err instanceof ApprovalTimeout) {
|
|
1016
|
+
// The asymmetric path. Silence is only ever a yes for something the
|
|
1017
|
+
// broker already pre-cleared — and that returned long before here — so
|
|
1018
|
+
// in practice this is always a deny. It reads the broker's own derived
|
|
1019
|
+
// flag rather than re-deriving its own idea of what is safe.
|
|
1020
|
+
const outcome = broker!.timeoutOutcome(brokered);
|
|
1021
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: outcome === 'approve' ? 'approved' : 'auto_denied' });
|
|
1022
|
+
if (outcome === 'approve') {
|
|
1023
|
+
log.warn(`[shieldcortex] approval broker: no answer in ${err.timeoutMs}ms — auto-approving pre-cleared ${context.toolName}`);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
log.warn(`[shieldcortex] approval broker: no answer in ${err.timeoutMs}ms — DENYING ${context.toolName} (fail-closed)`);
|
|
1027
|
+
throw new Error(`ShieldCortex: tool call blocked — no answer from the operator within ${err.timeoutMs}ms (fail-closed)`);
|
|
1028
|
+
}
|
|
741
1029
|
const failAction = config.failurePolicy[severity];
|
|
742
1030
|
log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
|
|
743
|
-
emitAudit({ ...
|
|
1031
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
|
|
744
1032
|
if (failAction === 'deny') {
|
|
745
1033
|
throw new Error('ShieldCortex: tool call blocked — approval error, failure policy: deny');
|
|
746
1034
|
}
|
|
@@ -748,14 +1036,18 @@ export function createInterceptor(
|
|
|
748
1036
|
}
|
|
749
1037
|
|
|
750
1038
|
if (approved) {
|
|
751
|
-
emitAudit({ ...
|
|
1039
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'approved' });
|
|
752
1040
|
return;
|
|
753
1041
|
}
|
|
754
|
-
emitAudit({ ...
|
|
1042
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'denied' });
|
|
755
1043
|
throw new Error('ShieldCortex: tool call denied by user');
|
|
756
1044
|
}
|
|
757
1045
|
|
|
758
1046
|
async function handleToolCall(context: ToolCallContext): Promise<void> {
|
|
1047
|
+
// Remember the NAME only. This is the entirety of what the approval broker's
|
|
1048
|
+
// judge will ever learn about the session — see buildSessionSummary.
|
|
1049
|
+
noteToolForSession(context.toolName);
|
|
1050
|
+
|
|
759
1051
|
// Non-memory tools go through the Action Guard (what the agent DOES); the
|
|
760
1052
|
// memory-write tools continue through the content defence pipeline below.
|
|
761
1053
|
if (!(WATCHED_TOOLS as readonly string[]).includes(context.toolName)) {
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.47.
|
|
3
|
+
"version": "4.47.19",
|
|
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