@drakon-systems/shieldcortex-realtime 4.47.17 → 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 +199 -7
- package/dist/openclaw.plugin.json +1 -1
- package/index.ts +95 -1
- package/interceptor.ts +301 -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'],
|
|
@@ -199,6 +200,8 @@ const FALLBACK_DANGEROUS_PATTERNS = [
|
|
|
199
200
|
{ re: /\btruncate\b[^|;&\n]*(?:-s\s*0\b|--size(?:=|\s+)0\b)/i, signal: 'truncate-to-zero' },
|
|
200
201
|
{ re: /\bhistory\s+-c\b|\.bash_history|truncate\b[^|\n]*\.log/i, signal: 'wipe-history-or-logs' },
|
|
201
202
|
{ re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
|
|
203
|
+
// Guard's own approval store (#118): agent-side writes here mint approvals.
|
|
204
|
+
{ re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
|
|
202
205
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
|
|
203
206
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
|
|
204
207
|
{ re: /\b(?:base64|openssl|xxd|cat|http)\b[^\n|]*\|(?:[^\n|]*\|)*\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:bash|sh|zsh|ksh|python\d?|perl|ruby|node)\b(?:\s+-)?\s*(?:[;&|\n]|$)/i, signal: 'decode-pipe-to-shell' },
|
|
@@ -374,6 +377,41 @@ export function createScriptSourceResolver(cwd) {
|
|
|
374
377
|
}
|
|
375
378
|
};
|
|
376
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
|
+
}
|
|
377
415
|
/** The cwd a tool call runs in, if the gateway or the call itself names one. */
|
|
378
416
|
function toolCallCwd(context) {
|
|
379
417
|
if (typeof context.cwd === 'string' && context.cwd)
|
|
@@ -385,6 +423,13 @@ function toolCallCwd(context) {
|
|
|
385
423
|
}
|
|
386
424
|
return undefined;
|
|
387
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;
|
|
388
433
|
export function createInterceptor(config, pipeline, options) {
|
|
389
434
|
const denyCache = new DenyCache();
|
|
390
435
|
const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
|
|
@@ -392,6 +437,13 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
392
437
|
const onAuditEntry = options?.onAuditEntry;
|
|
393
438
|
const actionGuardCfg = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
|
|
394
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 = [];
|
|
395
447
|
function emitAudit(entry) {
|
|
396
448
|
writeAuditEntry(entry);
|
|
397
449
|
onAuditEntry?.(entry);
|
|
@@ -406,6 +458,97 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
406
458
|
preview: preview.slice(0, 200), ts: new Date().toISOString(),
|
|
407
459
|
};
|
|
408
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
|
+
}
|
|
409
552
|
// WS2 fail-closed path (issue #59): when the real guard was never wired in or
|
|
410
553
|
// throws, run the dependency-free fallback scan. Three tiers, so no dangerous
|
|
411
554
|
// op is ever silently allowed on a scan failure — and every could-not-scan
|
|
@@ -526,44 +669,93 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
526
669
|
emitAudit({ ...base, action: 'warn', outcome: 'warned' });
|
|
527
670
|
return;
|
|
528
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
|
+
}
|
|
529
697
|
if (typeof context.requireApproval !== 'function') {
|
|
530
698
|
// Unattended (no approver, e.g. cron/heartbeat): fail closed on the failure
|
|
531
699
|
// policy. High-severity dangerous defaults to deny — surfaced loudly to the
|
|
532
700
|
// gateway log so an operator sees it, because a silent no-op is exactly the
|
|
533
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.
|
|
534
707
|
const failAction = config.failurePolicy[severity];
|
|
535
|
-
|
|
536
|
-
|
|
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) {
|
|
537
712
|
log.warn(`[shieldcortex] action-guard DENIED (unattended, no approver) ${context.toolName}: ${v.reason} [${v.signals.join(", ")}]`);
|
|
538
713
|
throw new Error(`ShieldCortex: tool call blocked — ${v.reason} (no approver, failure policy: deny)`);
|
|
539
714
|
}
|
|
540
715
|
return;
|
|
541
716
|
}
|
|
542
717
|
if (!rateLimiter.shouldAllow()) {
|
|
543
|
-
emitAudit({ ...
|
|
718
|
+
emitAudit({ ...auditBase, action: 'rate_limit', outcome: 'auto_denied' });
|
|
544
719
|
throw new Error('ShieldCortex: tool call auto-denied (approval rate limit exceeded)');
|
|
545
720
|
}
|
|
546
721
|
let approved;
|
|
547
722
|
try {
|
|
548
|
-
approved = await context.requireApproval(formatActionGuardPrompt(context.toolName, v));
|
|
723
|
+
approved = await withApprovalDeadline(context.requireApproval(formatActionGuardPrompt(context.toolName, v)), brokered ? brokerApprovalTimeoutMs(v.severity) : 0);
|
|
549
724
|
}
|
|
550
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
|
+
}
|
|
551
740
|
const failAction = config.failurePolicy[severity];
|
|
552
741
|
log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
|
|
553
|
-
emitAudit({ ...
|
|
742
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
|
|
554
743
|
if (failAction === 'deny') {
|
|
555
744
|
throw new Error('ShieldCortex: tool call blocked — approval error, failure policy: deny');
|
|
556
745
|
}
|
|
557
746
|
return;
|
|
558
747
|
}
|
|
559
748
|
if (approved) {
|
|
560
|
-
emitAudit({ ...
|
|
749
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'approved' });
|
|
561
750
|
return;
|
|
562
751
|
}
|
|
563
|
-
emitAudit({ ...
|
|
752
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'denied' });
|
|
564
753
|
throw new Error('ShieldCortex: tool call denied by user');
|
|
565
754
|
}
|
|
566
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);
|
|
567
759
|
// Non-memory tools go through the Action Guard (what the agent DOES); the
|
|
568
760
|
// memory-write tools continue through the content defence pipeline below.
|
|
569
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;
|
|
@@ -323,6 +409,8 @@ const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string }> = [
|
|
|
323
409
|
{ re: /\btruncate\b[^|;&\n]*(?:-s\s*0\b|--size(?:=|\s+)0\b)/i, signal: 'truncate-to-zero' },
|
|
324
410
|
{ re: /\bhistory\s+-c\b|\.bash_history|truncate\b[^|\n]*\.log/i, signal: 'wipe-history-or-logs' },
|
|
325
411
|
{ re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
|
|
412
|
+
// Guard's own approval store (#118): agent-side writes here mint approvals.
|
|
413
|
+
{ re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
|
|
326
414
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
|
|
327
415
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
|
|
328
416
|
{ re: /\b(?:base64|openssl|xxd|cat|http)\b[^\n|]*\|(?:[^\n|]*\|)*\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:bash|sh|zsh|ksh|python\d?|perl|ruby|node)\b(?:\s+-)?\s*(?:[;&|\n]|$)/i, signal: 'decode-pipe-to-shell' },
|
|
@@ -536,6 +624,39 @@ export function createScriptSourceResolver(cwd?: string): (scriptPath: string) =
|
|
|
536
624
|
};
|
|
537
625
|
}
|
|
538
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
|
+
|
|
539
660
|
/** The cwd a tool call runs in, if the gateway or the call itself names one. */
|
|
540
661
|
function toolCallCwd(context: ToolCallContext): string | undefined {
|
|
541
662
|
if (typeof context.cwd === 'string' && context.cwd) return context.cwd;
|
|
@@ -551,8 +672,24 @@ interface InterceptorOptions {
|
|
|
551
672
|
onAuditEntry?: (entry: InterceptAuditEntry) => void;
|
|
552
673
|
/** Tool Action Guard evaluator, injected from `shieldcortex/defence` at runtime. */
|
|
553
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;
|
|
554
683
|
}
|
|
555
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
|
+
|
|
556
693
|
export function createInterceptor(
|
|
557
694
|
config: InterceptorConfig,
|
|
558
695
|
pipeline: PipelineRunner,
|
|
@@ -567,6 +704,13 @@ export function createInterceptor(
|
|
|
567
704
|
const onAuditEntry = options?.onAuditEntry;
|
|
568
705
|
const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
|
|
569
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[] = [];
|
|
570
714
|
|
|
571
715
|
function emitAudit(entry: InterceptAuditEntry): void {
|
|
572
716
|
writeAuditEntry(entry);
|
|
@@ -584,6 +728,100 @@ export function createInterceptor(
|
|
|
584
728
|
};
|
|
585
729
|
}
|
|
586
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
|
+
|
|
587
825
|
// WS2 fail-closed path (issue #59): when the real guard was never wired in or
|
|
588
826
|
// throws, run the dependency-free fallback scan. Three tiers, so no dangerous
|
|
589
827
|
// op is ever silently allowed on a scan failure — and every could-not-scan
|
|
@@ -713,14 +951,49 @@ export function createInterceptor(
|
|
|
713
951
|
return;
|
|
714
952
|
}
|
|
715
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
|
+
|
|
716
982
|
if (typeof context.requireApproval !== 'function') {
|
|
717
983
|
// Unattended (no approver, e.g. cron/heartbeat): fail closed on the failure
|
|
718
984
|
// policy. High-severity dangerous defaults to deny — surfaced loudly to the
|
|
719
985
|
// gateway log so an operator sees it, because a silent no-op is exactly the
|
|
720
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.
|
|
721
992
|
const failAction = config.failurePolicy[severity];
|
|
722
|
-
|
|
723
|
-
|
|
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) {
|
|
724
997
|
log.warn(`[shieldcortex] action-guard DENIED (unattended, no approver) ${context.toolName}: ${v.reason} [${v.signals.join(", ")}]`);
|
|
725
998
|
throw new Error(`ShieldCortex: tool call blocked — ${v.reason} (no approver, failure policy: deny)`);
|
|
726
999
|
}
|
|
@@ -728,17 +1001,34 @@ export function createInterceptor(
|
|
|
728
1001
|
}
|
|
729
1002
|
|
|
730
1003
|
if (!rateLimiter.shouldAllow()) {
|
|
731
|
-
emitAudit({ ...
|
|
1004
|
+
emitAudit({ ...auditBase, action: 'rate_limit', outcome: 'auto_denied' });
|
|
732
1005
|
throw new Error('ShieldCortex: tool call auto-denied (approval rate limit exceeded)');
|
|
733
1006
|
}
|
|
734
1007
|
|
|
735
1008
|
let approved: boolean;
|
|
736
1009
|
try {
|
|
737
|
-
approved = await
|
|
1010
|
+
approved = await withApprovalDeadline(
|
|
1011
|
+
context.requireApproval(formatActionGuardPrompt(context.toolName, v)),
|
|
1012
|
+
brokered ? brokerApprovalTimeoutMs(v.severity) : 0,
|
|
1013
|
+
);
|
|
738
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
|
+
}
|
|
739
1029
|
const failAction = config.failurePolicy[severity];
|
|
740
1030
|
log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
|
|
741
|
-
emitAudit({ ...
|
|
1031
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
|
|
742
1032
|
if (failAction === 'deny') {
|
|
743
1033
|
throw new Error('ShieldCortex: tool call blocked — approval error, failure policy: deny');
|
|
744
1034
|
}
|
|
@@ -746,14 +1036,18 @@ export function createInterceptor(
|
|
|
746
1036
|
}
|
|
747
1037
|
|
|
748
1038
|
if (approved) {
|
|
749
|
-
emitAudit({ ...
|
|
1039
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'approved' });
|
|
750
1040
|
return;
|
|
751
1041
|
}
|
|
752
|
-
emitAudit({ ...
|
|
1042
|
+
emitAudit({ ...auditBase, action: 'require_approval', outcome: 'denied' });
|
|
753
1043
|
throw new Error('ShieldCortex: tool call denied by user');
|
|
754
1044
|
}
|
|
755
1045
|
|
|
756
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
|
+
|
|
757
1051
|
// Non-memory tools go through the Action Guard (what the agent DOES); the
|
|
758
1052
|
// memory-write tools continue through the content defence pipeline below.
|
|
759
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