@drakon-systems/shieldcortex-realtime 4.47.18 → 4.47.20

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.
@@ -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',
@@ -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
- emitAudit({ ...base, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
538
- if (failAction === 'deny') {
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({ ...base, action: 'rate_limit', outcome: 'auto_denied' });
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({ ...base, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
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({ ...base, action: 'require_approval', outcome: 'approved' });
749
+ emitAudit({ ...auditBase, action: 'require_approval', outcome: 'approved' });
563
750
  return;
564
751
  }
565
- emitAudit({ ...base, action: 'require_approval', outcome: 'denied' });
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.18",
3
+ "version": "4.47.20",
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
- emitAudit({ ...base, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
725
- if (failAction === 'deny') {
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({ ...base, action: 'rate_limit', outcome: 'auto_denied' });
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 context.requireApproval(formatActionGuardPrompt(context.toolName, v));
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({ ...base, action: 'require_approval', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
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({ ...base, action: 'require_approval', outcome: 'approved' });
1039
+ emitAudit({ ...auditBase, action: 'require_approval', outcome: 'approved' });
752
1040
  return;
753
1041
  }
754
- emitAudit({ ...base, action: 'require_approval', outcome: 'denied' });
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)) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.18",
3
+ "version": "4.47.20",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.47.18",
3
+ "version": "4.47.20",
4
4
  "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",