@drakon-systems/shieldcortex-realtime 4.50.0 → 4.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,8 @@ The defensive root `openclaw.plugin.json` is kept for one release on the main pa
32
32
  | `before_agent_run` | **The conversation firewall's enforcement point.** The documented input gate — it is awaited and its result decides whether the run proceeds. Behaviour is set by `interceptor.conversation.posture` (see [Conversation firewall](#conversation-firewall)). |
33
33
  | `llm_output` | Extracts high-signal memories from assistant replies and writes them into ShieldCortex with novelty filtering and dedupe. |
34
34
  | `before_tool_call` | Runs the Action Guard before tools execute. Catastrophic shell/file/network/git actions are always blocked. Recognised-dangerous actions are **enforced by default**: attended sessions get an approval prompt, unattended sessions fail closed per `failurePolicy`. Set `actionGuard.enforce: false` to opt down to warn-and-allow, or pre-approve specific operations with `actionGuard.autoApprove`. |
35
- | `session_end` | Resets the interceptor's per-session caches and releases that session's scan-unavailable alert window. Registered even when `interceptor.enabled` is `false`, because the conversation gate keeps per-session state regardless. |
35
+ | `session_end` | Resets the interceptor's per-session caches, releases that session's scan-unavailable alert window, and (with `agent_end`) writes `action_guard_degraded` when the Action Guard denied or warned during the session. Registered even when `interceptor.enabled` is `false`, because the conversation gate keeps per-session state regardless. Neither hook can block, approve, or delay a turn. |
36
+ | `agent_end` | Same degraded-run summariser as `session_end`, idempotent with it. Present on OpenClaw 2026.5.7+; an older host warns-and-returns and `session_end` still summarises. |
36
37
  | `/shieldcortex-status` | Slash command reporting the plugin's runtime state. |
37
38
 
38
39
  The scanning and memory paths are fire-and-forget: they do not stall the OpenClaw turn loop if ShieldCortex is unavailable. The Action Guard is the deliberate exception — it gates tool calls inline, and since 4.47.5 a guard that fails to load falls back to a dependency-free scanner that still denies unambiguous catastrophic operations (fail-closed) rather than allowing everything.
package/dist/index.js CHANGED
@@ -239,6 +239,24 @@ export function __setDefenceModuleForTest(mod) {
239
239
  _defenceModOverride = mod;
240
240
  _defenceModPromise = null;
241
241
  }
242
+ /** #260 — emit action_guard_degraded for this session. Never throws, never
243
+ * waits when the defence module is already injected (the test / in-process
244
+ * path). A missing module is a silent no-op: session_end cannot block (#112). */
245
+ function summariseGuardSession(sessionId, origin) {
246
+ if (!sessionId)
247
+ return;
248
+ const run = (mod) => {
249
+ try {
250
+ mod?.recordActionGuardDegraded?.(sessionId, { origin });
251
+ }
252
+ catch { /* never wedge */ }
253
+ };
254
+ if (_defenceModOverride !== undefined) {
255
+ run(_defenceModOverride);
256
+ return;
257
+ }
258
+ void getDefenceModule().then(run).catch(() => { });
259
+ }
242
260
  export function __setRuntimeForTest(runtime) {
243
261
  _runtimeOverride = runtime;
244
262
  if (runtime)
@@ -1690,8 +1708,23 @@ const MIN_NOVELTY_CHARS = 40;
1690
1708
  async function auditLog(entry) {
1691
1709
  const dir = auditDir();
1692
1710
  try {
1711
+ const hookName = typeof entry.hook === 'string' && entry.hook ? entry.hook : 'llm_input';
1712
+ const plane = hookName === 'before_tool_call' ? 'action_guard' : 'conversation_firewall';
1713
+ let bound = entry;
1714
+ try {
1715
+ const defenceMod = await getDefenceModule();
1716
+ if (typeof defenceMod?.attachEnforcementBinding === 'function') {
1717
+ bound = defenceMod.attachEnforcementBinding(entry, {
1718
+ plane,
1719
+ hookName,
1720
+ pluginId: 'shieldcortex-realtime',
1721
+ actionKey: typeof entry.actionKey === 'string' ? entry.actionKey : `conversation:${hookName}`,
1722
+ });
1723
+ }
1724
+ }
1725
+ catch { /* older package / bind failure — write the unbound row */ }
1693
1726
  await fs.mkdir(dir, { recursive: true });
1694
- await fs.appendFile(path.join(dir, `realtime-${new Date().toISOString().slice(0, 10)}.jsonl`), JSON.stringify(entry) + "\n");
1727
+ await fs.appendFile(path.join(dir, `realtime-${new Date().toISOString().slice(0, 10)}.jsonl`), JSON.stringify(bound) + "\n");
1695
1728
  return true;
1696
1729
  }
1697
1730
  catch (err) {
@@ -2728,6 +2761,10 @@ function resolveBrokerRuntime(defenceMod, rawBrokerConfig, api) {
2728
2761
  return {
2729
2762
  config,
2730
2763
  runJudge: defenceMod.runJudge,
2764
+ // #143 residual, and deliberately NOT in `needed`: an older shieldcortex
2765
+ // build has runJudge alone, and the interceptor falls back to it. Missing
2766
+ // it costs an audit field, never a gate.
2767
+ runJudgeDetailed: typeof defenceMod.runJudgeDetailed === 'function' ? defenceMod.runJudgeDetailed : undefined,
2731
2768
  brokerDecision: defenceMod.brokerDecision,
2732
2769
  timeoutOutcome: defenceMod.timeoutOutcome,
2733
2770
  approvalTimeoutMs: typeof defenceMod.approvalTimeoutMs === 'function' ? defenceMod.approvalTimeoutMs : undefined,
@@ -2910,11 +2947,31 @@ export default {
2910
2947
  releaseActionLease: typeof defenceMod.releaseToolCallLease === 'function'
2911
2948
  ? (toolName, args, sessionId) => defenceMod.releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
2912
2949
  : undefined,
2950
+ // #260: the session-guard index. Same formula as the Claude Code
2951
+ // hook. Absent on an older dist — then emitAudit still stamps origin
2952
+ // but does not write an index nobody would summarise.
2953
+ sessionGuard: typeof defenceMod.sessionKeyFor === 'function' && typeof defenceMod.appendSessionGuardIndex === 'function'
2954
+ ? {
2955
+ keyFor: (sessionId) => defenceMod.sessionKeyFor(sessionId),
2956
+ index: (entry) => {
2957
+ defenceMod.appendSessionGuardIndex({ entry: { ...entry } });
2958
+ },
2959
+ }
2960
+ : undefined,
2913
2961
  onAuditEntry: (entry) => syncInterceptEvent(entry, {
2914
2962
  cloudApiKey: scConfig.cloudApiKey ?? '',
2915
2963
  cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
2916
2964
  cloudEnabled: scConfig.cloudEnabled ?? false,
2917
2965
  }),
2966
+ bindAudit: typeof defenceMod.attachEnforcementBinding === 'function'
2967
+ ? (entry, args) => defenceMod.attachEnforcementBinding(entry, {
2968
+ plane: 'action_guard',
2969
+ hookName: 'before_tool_call',
2970
+ pluginId: 'shieldcortex-realtime',
2971
+ tool: entry.tool,
2972
+ args: args ?? {},
2973
+ })
2974
+ : undefined,
2918
2975
  });
2919
2976
  const guardState = interceptorConfig.actionGuard?.enabled
2920
2977
  ? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
@@ -2976,8 +3033,9 @@ export default {
2976
3033
  // `before_tool_call`: a registered approval hook changes how OpenClaw
2977
3034
  // resolves tool-call approvals for unattended Codex agents, so an
2978
3035
  // unattended turn waited 120s on a decision nobody could give. `session_end`
2979
- // is a notification — it cannot block, approve, or delay anything — and its
2980
- // handler here only frees local state.
3036
+ // is a notification — it cannot block, approve, or delay anything. It frees
3037
+ // local state and, since #260, best-effort summarises a degraded Action
3038
+ // Guard session. That write is not a decision and cannot stall the host.
2981
3039
  try {
2982
3040
  api.on('session_end', (event, ctx) => {
2983
3041
  interceptorReady?.resetSession();
@@ -2991,11 +3049,28 @@ export default {
2991
3049
  // per-session rule, for the same reason.
2992
3050
  if (endedSession)
2993
3051
  sessionTaint.clear(endedSession);
3052
+ // #260: plane-native summariser. session_end cannot block (#112) —
3053
+ // this is a notification hook. The write is best-effort and
3054
+ // idempotent with agent_end below.
3055
+ summariseGuardSession(endedSession, 'openclaw-session-end');
2994
3056
  });
2995
3057
  }
2996
3058
  catch {
2997
3059
  // session_end may not be a supported hook — TTL safety net handles this
2998
3060
  }
3061
+ // #260: agent_end exists on the engine floor (2026.5.7 already declared
3062
+ // it). An unknown typed hook is warn-and-return, not a throw, so we still
3063
+ // wrap registration. Same summariser as session_end — whichever fires
3064
+ // first writes, the other is a no-op. Do not invent a third sink.
3065
+ try {
3066
+ api.on('agent_end', (event, ctx) => {
3067
+ const endedSession = ctx?.sessionId ?? ctx?.sessionKey ?? event?.sessionId ?? event?.sessionKey ?? null;
3068
+ summariseGuardSession(endedSession, 'openclaw-session-end');
3069
+ });
3070
+ }
3071
+ catch {
3072
+ // Host predates agent_end — session_end is the load-bearing summariser.
3073
+ }
2999
3074
  // llm_input/llm_output are CONVERSATION hooks: OpenClaw drops them at
3000
3075
  // registration for a non-bundled plugin unless the host grants
3001
3076
  // plugins.entries.<id>.hooks.allowConversationAccess = true. Registration is
@@ -509,6 +509,10 @@ export function createInterceptor(config, pipeline, options) {
509
509
  const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
510
510
  const log = config.logger ?? { info: console.log, warn: console.warn };
511
511
  const onAuditEntry = options?.onAuditEntry;
512
+ let lastSessionId;
513
+ const bindAudit = options?.bindAudit;
514
+ /** Args of the in-flight tool call — used only to mint #224 actionKey. */
515
+ let lastCallArgs;
512
516
  const actionGuardCfg = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
513
517
  const evaluateToolCall = options?.evaluateToolCall;
514
518
  const broker = options?.broker;
@@ -519,8 +523,19 @@ export function createInterceptor(config, pipeline, options) {
519
523
  /** Bare tool names seen this session, newest last. See buildSessionSummary. */
520
524
  const recentTools = [];
521
525
  function emitAudit(entry) {
522
- writeAuditEntry(entry);
523
- onAuditEntry?.(entry);
526
+ const sessionKey = options?.sessionGuard?.keyFor(lastSessionId) ?? undefined;
527
+ const withOrigin = {
528
+ ...entry,
529
+ origin: 'openclaw-interceptor',
530
+ ...(sessionKey ? { sessionKey } : {}),
531
+ };
532
+ const bound = bindAudit ? bindAudit(withOrigin, lastCallArgs) : withOrigin;
533
+ writeAuditEntry(bound);
534
+ try {
535
+ options?.sessionGuard?.index(bound);
536
+ }
537
+ catch { /* never wedge the turn */ }
538
+ onAuditEntry?.(bound);
524
539
  }
525
540
  function guardAuditBase(toolName, v, preview) {
526
541
  return {
@@ -587,13 +602,29 @@ export function createInterceptor(config, pipeline, options) {
587
602
  timeoutMs: broker.config.judgeTimeoutMs,
588
603
  });
589
604
  let judge = null;
605
+ // Only set when a judge pass actually ran: "no seam on this build" and
606
+ // "budget spent" are not timeouts, and claiming otherwise would be the
607
+ // same overclaim in the audit that this residual removes from doctor.
608
+ let judgeMeta;
590
609
  if (invoke && judgeLimiter.shouldAllow()) {
591
- judge = await broker.runJudge({
610
+ const request = {
592
611
  tool: context.toolName,
593
612
  toolInput: context.arguments,
594
613
  verdict: { severity: v.severity, action: v.action, reason: v.reason, signals: v.signals },
595
614
  sessionSummary: buildSessionSummary(),
596
- }, invoke, { timeoutMs: broker.config.judgeTimeoutMs });
615
+ };
616
+ const opts = { timeoutMs: broker.config.judgeTimeoutMs };
617
+ if (typeof broker.runJudgeDetailed === 'function') {
618
+ const detailed = await broker.runJudgeDetailed(request, invoke, opts);
619
+ judge = detailed?.result ?? null;
620
+ judgeMeta = {
621
+ timedOut: detailed?.timedOut === true,
622
+ error: typeof detailed?.error === 'string' ? detailed.error : null,
623
+ };
624
+ }
625
+ else {
626
+ judge = await broker.runJudge(request, invoke, opts);
627
+ }
597
628
  }
598
629
  else if (invoke) {
599
630
  log.warn(`[shieldcortex] approval broker: judge budget spent this minute — holding ${context.toolName} for the operator`);
@@ -603,6 +634,7 @@ export function createInterceptor(config, pipeline, options) {
603
634
  toolInput: context.arguments,
604
635
  verdict: v,
605
636
  judge,
637
+ ...(judgeMeta ? { judgeMeta } : {}),
606
638
  policy: {
607
639
  allowPreClear: broker.config.allowPreClear,
608
640
  preClearConfidence: broker.config.preClearConfidence,
@@ -910,6 +942,8 @@ export function createInterceptor(config, pipeline, options) {
910
942
  throw new Error('ShieldCortex: tool call denied by user');
911
943
  }
912
944
  async function handleToolCall(context) {
945
+ lastSessionId = context.sessionId;
946
+ lastCallArgs = context.arguments;
913
947
  // Remember the NAME only. This is the entirety of what the approval broker's
914
948
  // judge will ever learn about the session — see buildSessionSummary.
915
949
  noteToolForSession(context.toolName);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.50.0",
3
+ "version": "4.52.0",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
@@ -18,7 +18,8 @@
18
18
  "llm_output",
19
19
  "before_agent_run",
20
20
  "before_tool_call",
21
- "session_end"
21
+ "session_end",
22
+ "agent_end"
22
23
  ],
23
24
  "commands": [
24
25
  "shieldcortex-status"
package/index.ts CHANGED
@@ -100,6 +100,27 @@ type DefenceModule = {
100
100
  notification: unknown,
101
101
  deps: { channels: NotifyChannelLike[]; timeoutMs?: number },
102
102
  ) => Promise<{ deliveredVia: string | null; attempts: Array<{ channel: string; result: { delivered: boolean; reason?: string } }> }>;
103
+ /** #260 — session-guard index + degraded-run summary. Optional so an older
104
+ * installed dist degrades to "no index" rather than crashing the hook. */
105
+ sessionKeyFor?: (value: string | undefined, opts?: { home?: string; salt?: string }) => string | null;
106
+ appendSessionGuardIndex?: (opts: { home?: string; entry: Record<string, unknown> }) => boolean;
107
+ recordActionGuardDegraded?: (
108
+ rawSessionId: string | undefined,
109
+ opts?: { home?: string; salt?: string; origin?: string },
110
+ ) => { recorded: boolean; count: number; sessionKey?: string; existing?: boolean };
111
+ /** #224 — stamp binding fields on a realtime audit row. Optional so an
112
+ * older installed package degrades to unbound records, not a crash. */
113
+ attachEnforcementBinding?: (
114
+ entry: Record<string, unknown>,
115
+ ctx: {
116
+ plane: 'action_guard' | 'conversation_firewall';
117
+ hookName: string;
118
+ pluginId: string;
119
+ tool?: string;
120
+ args?: Record<string, unknown>;
121
+ actionKey?: string;
122
+ },
123
+ ) => Record<string, unknown>;
103
124
  };
104
125
 
105
126
  let runtimePromise: Promise<OpenClawRuntime> | null = null;
@@ -322,6 +343,21 @@ export function __setDefenceModuleForTest(mod: DefenceModule | null | undefined)
322
343
  _defenceModOverride = mod;
323
344
  _defenceModPromise = null;
324
345
  }
346
+
347
+ /** #260 — emit action_guard_degraded for this session. Never throws, never
348
+ * waits when the defence module is already injected (the test / in-process
349
+ * path). A missing module is a silent no-op: session_end cannot block (#112). */
350
+ function summariseGuardSession(sessionId: string | null, origin: string): void {
351
+ if (!sessionId) return;
352
+ const run = (mod: DefenceModule | null) => {
353
+ try { mod?.recordActionGuardDegraded?.(sessionId, { origin }); } catch { /* never wedge */ }
354
+ };
355
+ if (_defenceModOverride !== undefined) {
356
+ run(_defenceModOverride);
357
+ return;
358
+ }
359
+ void getDefenceModule().then(run).catch(() => {});
360
+ }
325
361
  export function __setRuntimeForTest(runtime: OpenClawRuntime | null): void {
326
362
  _runtimeOverride = runtime;
327
363
  if (runtime) runtimePromise = null;
@@ -2036,10 +2072,24 @@ const MIN_NOVELTY_CHARS = 40;
2036
2072
  async function auditLog(entry: Record<string, unknown>): Promise<boolean> {
2037
2073
  const dir = auditDir();
2038
2074
  try {
2075
+ const hookName = typeof entry.hook === 'string' && entry.hook ? entry.hook : 'llm_input';
2076
+ const plane = hookName === 'before_tool_call' ? 'action_guard' : 'conversation_firewall';
2077
+ let bound = entry;
2078
+ try {
2079
+ const defenceMod = await getDefenceModule();
2080
+ if (typeof defenceMod?.attachEnforcementBinding === 'function') {
2081
+ bound = defenceMod.attachEnforcementBinding(entry, {
2082
+ plane,
2083
+ hookName,
2084
+ pluginId: 'shieldcortex-realtime',
2085
+ actionKey: typeof entry.actionKey === 'string' ? entry.actionKey : `conversation:${hookName}`,
2086
+ });
2087
+ }
2088
+ } catch { /* older package / bind failure — write the unbound row */ }
2039
2089
  await fs.mkdir(dir, { recursive: true });
2040
2090
  await fs.appendFile(
2041
2091
  path.join(dir, `realtime-${new Date().toISOString().slice(0, 10)}.jsonl`),
2042
- JSON.stringify(entry) + "\n",
2092
+ JSON.stringify(bound) + "\n",
2043
2093
  );
2044
2094
  return true;
2045
2095
  } catch (err) {
@@ -3250,6 +3300,11 @@ function resolveBrokerRuntime(
3250
3300
  return {
3251
3301
  config,
3252
3302
  runJudge: defenceMod.runJudge,
3303
+ // #143 residual, and deliberately NOT in `needed`: an older shieldcortex
3304
+ // build has runJudge alone, and the interceptor falls back to it. Missing
3305
+ // it costs an audit field, never a gate.
3306
+ runJudgeDetailed:
3307
+ typeof defenceMod.runJudgeDetailed === 'function' ? defenceMod.runJudgeDetailed : undefined,
3253
3308
  brokerDecision: defenceMod.brokerDecision,
3254
3309
  timeoutOutcome: defenceMod.timeoutOutcome,
3255
3310
  approvalTimeoutMs: typeof defenceMod.approvalTimeoutMs === 'function' ? defenceMod.approvalTimeoutMs : undefined,
@@ -3443,11 +3498,31 @@ export default {
3443
3498
  ? (toolName, args, sessionId) =>
3444
3499
  (defenceMod as any).releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
3445
3500
  : undefined,
3501
+ // #260: the session-guard index. Same formula as the Claude Code
3502
+ // hook. Absent on an older dist — then emitAudit still stamps origin
3503
+ // but does not write an index nobody would summarise.
3504
+ sessionGuard: typeof defenceMod.sessionKeyFor === 'function' && typeof defenceMod.appendSessionGuardIndex === 'function'
3505
+ ? {
3506
+ keyFor: (sessionId) => defenceMod.sessionKeyFor!(sessionId),
3507
+ index: (entry) => {
3508
+ defenceMod.appendSessionGuardIndex!({ entry: { ...entry } as Record<string, unknown> });
3509
+ },
3510
+ }
3511
+ : undefined,
3446
3512
  onAuditEntry: (entry) => syncInterceptEvent(entry, {
3447
3513
  cloudApiKey: (scConfig as any).cloudApiKey ?? '',
3448
3514
  cloudBaseUrl: (scConfig as any).cloudBaseUrl ?? 'https://api.shieldcortex.ai',
3449
3515
  cloudEnabled: (scConfig as any).cloudEnabled ?? false,
3450
3516
  }),
3517
+ bindAudit: typeof (defenceMod as any).attachEnforcementBinding === 'function'
3518
+ ? (entry, args) => (defenceMod as any).attachEnforcementBinding(entry, {
3519
+ plane: 'action_guard',
3520
+ hookName: 'before_tool_call',
3521
+ pluginId: 'shieldcortex-realtime',
3522
+ tool: entry.tool,
3523
+ args: args ?? {},
3524
+ }) as typeof entry
3525
+ : undefined,
3451
3526
  });
3452
3527
  const guardState = interceptorConfig.actionGuard?.enabled
3453
3528
  ? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
@@ -3509,8 +3584,9 @@ export default {
3509
3584
  // `before_tool_call`: a registered approval hook changes how OpenClaw
3510
3585
  // resolves tool-call approvals for unattended Codex agents, so an
3511
3586
  // unattended turn waited 120s on a decision nobody could give. `session_end`
3512
- // is a notification — it cannot block, approve, or delay anything — and its
3513
- // handler here only frees local state.
3587
+ // is a notification — it cannot block, approve, or delay anything. It frees
3588
+ // local state and, since #260, best-effort summarises a degraded Action
3589
+ // Guard session. That write is not a decision and cannot stall the host.
3514
3590
  try {
3515
3591
  api.on('session_end', (event?: { sessionId?: string; sessionKey?: string }, ctx?: AgentCtx) => {
3516
3592
  interceptorReady?.resetSession();
@@ -3523,11 +3599,28 @@ export default {
3523
3599
  // #233: a taint must not outlive the conversation that earned it. Same
3524
3600
  // per-session rule, for the same reason.
3525
3601
  if (endedSession) sessionTaint.clear(endedSession);
3602
+ // #260: plane-native summariser. session_end cannot block (#112) —
3603
+ // this is a notification hook. The write is best-effort and
3604
+ // idempotent with agent_end below.
3605
+ summariseGuardSession(endedSession, 'openclaw-session-end');
3526
3606
  });
3527
3607
  } catch {
3528
3608
  // session_end may not be a supported hook — TTL safety net handles this
3529
3609
  }
3530
3610
 
3611
+ // #260: agent_end exists on the engine floor (2026.5.7 already declared
3612
+ // it). An unknown typed hook is warn-and-return, not a throw, so we still
3613
+ // wrap registration. Same summariser as session_end — whichever fires
3614
+ // first writes, the other is a no-op. Do not invent a third sink.
3615
+ try {
3616
+ api.on('agent_end', (event?: { sessionId?: string; sessionKey?: string }, ctx?: AgentCtx) => {
3617
+ const endedSession = ctx?.sessionId ?? ctx?.sessionKey ?? event?.sessionId ?? event?.sessionKey ?? null;
3618
+ summariseGuardSession(endedSession, 'openclaw-session-end');
3619
+ });
3620
+ } catch {
3621
+ // Host predates agent_end — session_end is the load-bearing summariser.
3622
+ }
3623
+
3531
3624
  // llm_input/llm_output are CONVERSATION hooks: OpenClaw drops them at
3532
3625
  // registration for a non-bundled plugin unless the host grants
3533
3626
  // plugins.entries.<id>.hooks.allowConversationAccess = true. Registration is
package/interceptor.ts CHANGED
@@ -53,8 +53,15 @@ export interface ToolGuardVerdictLike {
53
53
  action: string;
54
54
  reason: string;
55
55
  signals: string[];
56
- /** Rule → matched-span evidence behind `signals` (issue #192). */
57
- matches?: Array<{ signal: string; span: string }>;
56
+ /** Rule → matched-span evidence behind `signals` (issue #192).
57
+ * #184: optional source/line/chain when the match came from folded script. */
58
+ matches?: Array<{
59
+ signal: string;
60
+ span: string;
61
+ source?: string;
62
+ line?: number;
63
+ chain?: string;
64
+ }>;
58
65
  /** Files the reviewed-script allowlist exempted from folding (#189). */
59
66
  reviewedScripts?: string[];
60
67
  }
@@ -100,6 +107,10 @@ export interface BrokerAuditLike {
100
107
  judgeConfidence: number | null;
101
108
  injectionSuspected: boolean;
102
109
  inContext: boolean | null;
110
+ /** #143 residual — "the judge never answered" told apart from "the judge said
111
+ * hold". Audit only; neither field can reach an outcome. */
112
+ judgeTimedOut?: boolean;
113
+ judgeUnavailableReason?: string | null;
103
114
  reason: string;
104
115
  }
105
116
 
@@ -135,11 +146,26 @@ export interface BrokerRuntime {
135
146
  invoke: ModelInvokerLike,
136
147
  opts?: { timeoutMs?: number },
137
148
  ) => Promise<JudgeResultLike | null>;
149
+ /** #143 residual, and OPTIONAL: this plugin is built across a package
150
+ * boundary, so a main package from before the residual injects `runJudge`
151
+ * alone and the pass below falls back to it. Absent costs an audit field,
152
+ * never a gate. */
153
+ runJudgeDetailed?: (
154
+ req: {
155
+ tool: string;
156
+ toolInput: unknown;
157
+ verdict: { severity: string; action: string; reason: string; signals: string[] };
158
+ sessionSummary?: string;
159
+ },
160
+ invoke: ModelInvokerLike,
161
+ opts?: { timeoutMs?: number },
162
+ ) => Promise<{ result: JudgeResultLike | null; timedOut: boolean; error?: string }>;
138
163
  brokerDecision: (input: {
139
164
  tool: string;
140
165
  toolInput: unknown;
141
166
  verdict: ToolGuardVerdictLike;
142
167
  judge: JudgeResultLike | null;
168
+ judgeMeta?: { timedOut?: boolean; error?: string | null };
143
169
  policy?: { allowPreClear: boolean; preClearConfidence: number };
144
170
  }) => BrokerDecisionLike;
145
171
  timeoutOutcome: (decision: BrokerDecisionLike) => 'approve' | 'deny';
@@ -202,6 +228,17 @@ export interface InterceptAuditEntry {
202
228
  escalated?: { by: 'session-taint'; from: string; to: string; reason: string };
203
229
  /** Files the reviewed-script allowlist exempted from folding (#189). */
204
230
  reviewedScripts?: string[];
231
+ /** #260 — plane origin so the session-guard summariser can find this row. */
232
+ origin?: 'openclaw-interceptor';
233
+ sessionKey?: string;
234
+ /** #224 — binding fields. Present once the host injects `bindAudit`. */
235
+ plane?: 'action_guard' | 'conversation_firewall';
236
+ gatewayInstanceId?: string;
237
+ hookName?: string;
238
+ pluginId?: string;
239
+ nonce?: string;
240
+ seq?: number;
241
+ actionKey?: string;
205
242
  }
206
243
 
207
244
  const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'] as const;
@@ -798,6 +835,15 @@ interface InterceptorOptions {
798
835
  * limit, so a looping or compromised agent must not be able to spend it
799
836
  * without bound. Exhausting it yields no judge, which yields a hold. */
800
837
  maxJudgeCallsPerMinute?: number;
838
+ /** #260 — session-guard index. Injected from `shieldcortex/defence`. */
839
+ sessionGuard?: {
840
+ keyFor: (sessionId: string | undefined) => string | null;
841
+ index: (entry: InterceptAuditEntry) => void;
842
+ };
843
+ /** #224 — stamp plane/instance/hook/nonce/seq/actionKey before the row hits
844
+ * disk. Injected from `shieldcortex/defence` at runtime so this plugin does
845
+ * not grow a second schema. Absent = unbound (older installed package). */
846
+ bindAudit?: (entry: InterceptAuditEntry, args?: Record<string, unknown>) => InterceptAuditEntry;
801
847
  }
802
848
 
803
849
  /** How many recent tool NAMES the judge is told about. Names only, never
@@ -820,6 +866,10 @@ export function createInterceptor(
820
866
  const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
821
867
  const log = config.logger ?? { info: console.log, warn: console.warn };
822
868
  const onAuditEntry = options?.onAuditEntry;
869
+ let lastSessionId: string | undefined;
870
+ const bindAudit = options?.bindAudit;
871
+ /** Args of the in-flight tool call — used only to mint #224 actionKey. */
872
+ let lastCallArgs: Record<string, unknown> | undefined;
823
873
  const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
824
874
  const evaluateToolCall = options?.evaluateToolCall;
825
875
  const broker = options?.broker;
@@ -831,8 +881,16 @@ export function createInterceptor(
831
881
  const recentTools: string[] = [];
832
882
 
833
883
  function emitAudit(entry: InterceptAuditEntry): void {
834
- writeAuditEntry(entry);
835
- onAuditEntry?.(entry);
884
+ const sessionKey = options?.sessionGuard?.keyFor(lastSessionId) ?? undefined;
885
+ const withOrigin: InterceptAuditEntry = {
886
+ ...entry,
887
+ origin: 'openclaw-interceptor',
888
+ ...(sessionKey ? { sessionKey } : {}),
889
+ };
890
+ const bound = bindAudit ? bindAudit(withOrigin, lastCallArgs) : withOrigin;
891
+ writeAuditEntry(bound);
892
+ try { options?.sessionGuard?.index(bound); } catch { /* never wedge the turn */ }
893
+ onAuditEntry?.(bound);
836
894
  }
837
895
 
838
896
  function guardAuditBase(toolName: string, v: ToolGuardVerdictLike, preview: string): Omit<InterceptAuditEntry, 'action' | 'outcome'> {
@@ -902,17 +960,28 @@ export function createInterceptor(
902
960
  });
903
961
 
904
962
  let judge: JudgeResultLike | null = null;
963
+ // Only set when a judge pass actually ran: "no seam on this build" and
964
+ // "budget spent" are not timeouts, and claiming otherwise would be the
965
+ // same overclaim in the audit that this residual removes from doctor.
966
+ let judgeMeta: { timedOut?: boolean; error?: string | null } | undefined;
905
967
  if (invoke && judgeLimiter.shouldAllow()) {
906
- judge = await broker.runJudge(
907
- {
908
- tool: context.toolName,
909
- toolInput: context.arguments,
910
- verdict: { severity: v.severity, action: v.action, reason: v.reason, signals: v.signals },
911
- sessionSummary: buildSessionSummary(),
912
- },
913
- invoke,
914
- { timeoutMs: broker.config.judgeTimeoutMs },
915
- );
968
+ const request = {
969
+ tool: context.toolName,
970
+ toolInput: context.arguments,
971
+ verdict: { severity: v.severity, action: v.action, reason: v.reason, signals: v.signals },
972
+ sessionSummary: buildSessionSummary(),
973
+ };
974
+ const opts = { timeoutMs: broker.config.judgeTimeoutMs };
975
+ if (typeof broker.runJudgeDetailed === 'function') {
976
+ const detailed = await broker.runJudgeDetailed(request, invoke, opts);
977
+ judge = detailed?.result ?? null;
978
+ judgeMeta = {
979
+ timedOut: detailed?.timedOut === true,
980
+ error: typeof detailed?.error === 'string' ? detailed.error : null,
981
+ };
982
+ } else {
983
+ judge = await broker.runJudge(request, invoke, opts);
984
+ }
916
985
  } else if (invoke) {
917
986
  log.warn(`[shieldcortex] approval broker: judge budget spent this minute — holding ${context.toolName} for the operator`);
918
987
  }
@@ -922,6 +991,7 @@ export function createInterceptor(
922
991
  toolInput: context.arguments,
923
992
  verdict: v,
924
993
  judge,
994
+ ...(judgeMeta ? { judgeMeta } : {}),
925
995
  policy: {
926
996
  allowPreClear: broker.config.allowPreClear,
927
997
  preClearConfidence: broker.config.preClearConfidence,
@@ -1249,6 +1319,8 @@ export function createInterceptor(
1249
1319
  }
1250
1320
 
1251
1321
  async function handleToolCall(context: ToolCallContext): Promise<void> {
1322
+ lastSessionId = context.sessionId;
1323
+ lastCallArgs = context.arguments;
1252
1324
  // Remember the NAME only. This is the entirety of what the approval broker's
1253
1325
  // judge will ever learn about the session — see buildSessionSummary.
1254
1326
  noteToolForSession(context.toolName);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.50.0",
3
+ "version": "4.52.0",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
@@ -18,7 +18,8 @@
18
18
  "llm_output",
19
19
  "before_agent_run",
20
20
  "before_tool_call",
21
- "session_end"
21
+ "session_end",
22
+ "agent_end"
22
23
  ],
23
24
  "commands": [
24
25
  "shieldcortex-status"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.50.0",
3
+ "version": "4.52.0",
4
4
  "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",