@drakon-systems/shieldcortex-realtime 4.49.0 → 4.51.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) {
@@ -2900,11 +2933,41 @@ export default {
2900
2933
  const rec = sessionId ? sessionTaint.get(sessionId) : null;
2901
2934
  return rec ? { reason: rec.reason } : null;
2902
2935
  },
2936
+ // #227: session action lease — the fs-backed shared implementation,
2937
+ // injected through the same runtime seam as evaluateToolCall. Older
2938
+ // installed packages without the export simply leave the option
2939
+ // undefined (no lease plane — the capability-honesty surface says so).
2940
+ checkActionLease: typeof defenceMod.evaluateToolCallLease === 'function'
2941
+ ? (toolName, args, sessionId) => defenceMod.evaluateToolCallLease(toolName, args, { self: sessionId ?? '' })
2942
+ : undefined,
2943
+ releaseActionLease: typeof defenceMod.releaseToolCallLease === 'function'
2944
+ ? (toolName, args, sessionId) => defenceMod.releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
2945
+ : undefined,
2946
+ // #260: the session-guard index. Same formula as the Claude Code
2947
+ // hook. Absent on an older dist — then emitAudit still stamps origin
2948
+ // but does not write an index nobody would summarise.
2949
+ sessionGuard: typeof defenceMod.sessionKeyFor === 'function' && typeof defenceMod.appendSessionGuardIndex === 'function'
2950
+ ? {
2951
+ keyFor: (sessionId) => defenceMod.sessionKeyFor(sessionId),
2952
+ index: (entry) => {
2953
+ defenceMod.appendSessionGuardIndex({ entry: { ...entry } });
2954
+ },
2955
+ }
2956
+ : undefined,
2903
2957
  onAuditEntry: (entry) => syncInterceptEvent(entry, {
2904
2958
  cloudApiKey: scConfig.cloudApiKey ?? '',
2905
2959
  cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
2906
2960
  cloudEnabled: scConfig.cloudEnabled ?? false,
2907
2961
  }),
2962
+ bindAudit: typeof defenceMod.attachEnforcementBinding === 'function'
2963
+ ? (entry, args) => defenceMod.attachEnforcementBinding(entry, {
2964
+ plane: 'action_guard',
2965
+ hookName: 'before_tool_call',
2966
+ pluginId: 'shieldcortex-realtime',
2967
+ tool: entry.tool,
2968
+ args: args ?? {},
2969
+ })
2970
+ : undefined,
2908
2971
  });
2909
2972
  const guardState = interceptorConfig.actionGuard?.enabled
2910
2973
  ? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
@@ -2966,8 +3029,9 @@ export default {
2966
3029
  // `before_tool_call`: a registered approval hook changes how OpenClaw
2967
3030
  // resolves tool-call approvals for unattended Codex agents, so an
2968
3031
  // unattended turn waited 120s on a decision nobody could give. `session_end`
2969
- // is a notification — it cannot block, approve, or delay anything — and its
2970
- // handler here only frees local state.
3032
+ // is a notification — it cannot block, approve, or delay anything. It frees
3033
+ // local state and, since #260, best-effort summarises a degraded Action
3034
+ // Guard session. That write is not a decision and cannot stall the host.
2971
3035
  try {
2972
3036
  api.on('session_end', (event, ctx) => {
2973
3037
  interceptorReady?.resetSession();
@@ -2981,11 +3045,28 @@ export default {
2981
3045
  // per-session rule, for the same reason.
2982
3046
  if (endedSession)
2983
3047
  sessionTaint.clear(endedSession);
3048
+ // #260: plane-native summariser. session_end cannot block (#112) —
3049
+ // this is a notification hook. The write is best-effort and
3050
+ // idempotent with agent_end below.
3051
+ summariseGuardSession(endedSession, 'openclaw-session-end');
2984
3052
  });
2985
3053
  }
2986
3054
  catch {
2987
3055
  // session_end may not be a supported hook — TTL safety net handles this
2988
3056
  }
3057
+ // #260: agent_end exists on the engine floor (2026.5.7 already declared
3058
+ // it). An unknown typed hook is warn-and-return, not a throw, so we still
3059
+ // wrap registration. Same summariser as session_end — whichever fires
3060
+ // first writes, the other is a no-op. Do not invent a third sink.
3061
+ try {
3062
+ api.on('agent_end', (event, ctx) => {
3063
+ const endedSession = ctx?.sessionId ?? ctx?.sessionKey ?? event?.sessionId ?? event?.sessionKey ?? null;
3064
+ summariseGuardSession(endedSession, 'openclaw-session-end');
3065
+ });
3066
+ }
3067
+ catch {
3068
+ // Host predates agent_end — session_end is the load-bearing summariser.
3069
+ }
2989
3070
  // llm_input/llm_output are CONVERSATION hooks: OpenClaw drops them at
2990
3071
  // registration for a non-bundled plugin unless the host grants
2991
3072
  // plugins.entries.<id>.hooks.allowConversationAccess = true. Registration is
@@ -203,6 +203,8 @@ const FALLBACK_DANGEROUS_PATTERNS = [
203
203
  { re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
204
204
  // Guard's own approval store (#118): agent-side writes here mint approvals.
205
205
  { re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
206
+ // Session-lease ledger + store (#227): a freeze an agent can edit is not a freeze.
207
+ { re: /\.shieldcortex[\\/]+(?:DECISIONS\.md|leases)\b/i, signal: 'touch-decisions-ledger' },
206
208
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
207
209
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
208
210
  { 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' },
@@ -507,6 +509,10 @@ export function createInterceptor(config, pipeline, options) {
507
509
  const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
508
510
  const log = config.logger ?? { info: console.log, warn: console.warn };
509
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;
510
516
  const actionGuardCfg = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
511
517
  const evaluateToolCall = options?.evaluateToolCall;
512
518
  const broker = options?.broker;
@@ -517,8 +523,19 @@ export function createInterceptor(config, pipeline, options) {
517
523
  /** Bare tool names seen this session, newest last. See buildSessionSummary. */
518
524
  const recentTools = [];
519
525
  function emitAudit(entry) {
520
- writeAuditEntry(entry);
521
- 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);
522
539
  }
523
540
  function guardAuditBase(toolName, v, preview) {
524
541
  return {
@@ -680,6 +697,34 @@ export function createInterceptor(config, pipeline, options) {
680
697
  async function runActionGuard(context) {
681
698
  if (!actionGuardCfg.enabled)
682
699
  return;
700
+ // Session action lease (#227) — EARLY, before the guard evaluator (which may
701
+ // be unwired or throw). A freeze is a HARD control and must bind regardless
702
+ // of the guard's own state; running it only after a successful evaluation
703
+ // would let a frozen action through the guard-unavailable path. Unscoped
704
+ // calls return null and cost nothing; a THROW is treated as no-lease (a
705
+ // broken lease layer must not deny everything); state unreadability fails
706
+ // closed to 'unknown' inside the store.
707
+ let leaseGate = null;
708
+ try {
709
+ leaseGate = options?.checkActionLease?.(context.toolName, context.arguments || {}, context.sessionId) ?? null;
710
+ if (leaseGate?.ledgerChanged) {
711
+ log.warn(`[shieldcortex] DECISIONS.md changed since last read (${leaseGate.ledgerChanged.fromHash.slice(0, 12)} → ${leaseGate.ledgerChanged.toHash.slice(0, 12)}) — tamper evidence, review the ledger`);
712
+ }
713
+ if (leaseGate && leaseGate.decision.verdict !== 'allow') {
714
+ emitAudit({
715
+ ...guardAuditBase(context.toolName, { decision: 'block', severity: 'high', family: 'exec', action: `session-lease:${leaseGate.scope}`, reason: leaseGate.decision.reason, signals: ['session-lease', leaseGate.decision.verdict] }, `${context.toolName} :: ${summariseToolArgs(context.arguments)}`),
716
+ action: 'auto_deny', outcome: 'auto_denied',
717
+ });
718
+ log.warn(`[shieldcortex] action-guard SESSION-LEASE refused ${context.toolName} [${leaseGate.scope}/${leaseGate.decision.verdict}]: ${leaseGate.decision.reason}`);
719
+ throw new Error(`ShieldCortex: tool call blocked — ${leaseGate.decision.reason}`);
720
+ }
721
+ }
722
+ catch (err) {
723
+ // A ShieldCortex refusal must propagate; a lease-layer malfunction must not.
724
+ if (err instanceof Error && err.message.startsWith('ShieldCortex:'))
725
+ throw err;
726
+ leaseGate = null;
727
+ }
683
728
  if (typeof evaluateToolCall !== 'function') {
684
729
  handleGuardUnavailable(context, 'evaluateToolCall not wired');
685
730
  return;
@@ -757,6 +802,14 @@ export function createInterceptor(config, pipeline, options) {
757
802
  const severity = v.severity === 'catastrophic' ? 'critical' : 'high';
758
803
  // Catastrophic / exfil — hard block, always enforced when the guard is enabled.
759
804
  if (v.decision === 'block') {
805
+ // #227: release any lease this call minted early — a blocked action must
806
+ // not leave a hold on that scope (self-heals at TTL if release fails).
807
+ if (leaseGate?.acquired) {
808
+ try {
809
+ options?.releaseActionLease?.(context.toolName, context.arguments || {}, context.sessionId);
810
+ }
811
+ catch { /* self-heals */ }
812
+ }
760
813
  emitAudit({ ...base, action: 'auto_deny', outcome: 'auto_denied' });
761
814
  // Surface the block to the gateway log (journald). Blocks are recorded in
762
815
  // the ShieldCortex audit jsonl, but were otherwise invisible to an operator
@@ -872,6 +925,8 @@ export function createInterceptor(config, pipeline, options) {
872
925
  throw new Error('ShieldCortex: tool call denied by user');
873
926
  }
874
927
  async function handleToolCall(context) {
928
+ lastSessionId = context.sessionId;
929
+ lastCallArgs = context.arguments;
875
930
  // Remember the NAME only. This is the entirety of what the approval broker's
876
931
  // judge will ever learn about the session — see buildSessionSummary.
877
932
  noteToolForSession(context.toolName);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.49.0",
3
+ "version": "4.51.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) {
@@ -3431,11 +3481,43 @@ export default {
3431
3481
  const rec = sessionId ? sessionTaint.get(sessionId) : null;
3432
3482
  return rec ? { reason: rec.reason } : null;
3433
3483
  },
3484
+ // #227: session action lease — the fs-backed shared implementation,
3485
+ // injected through the same runtime seam as evaluateToolCall. Older
3486
+ // installed packages without the export simply leave the option
3487
+ // undefined (no lease plane — the capability-honesty surface says so).
3488
+ checkActionLease: typeof (defenceMod as any).evaluateToolCallLease === 'function'
3489
+ ? (toolName, args, sessionId) =>
3490
+ (defenceMod as any).evaluateToolCallLease(toolName, args, { self: sessionId ?? '' })
3491
+ : undefined,
3492
+ releaseActionLease: typeof (defenceMod as any).releaseToolCallLease === 'function'
3493
+ ? (toolName, args, sessionId) =>
3494
+ (defenceMod as any).releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
3495
+ : undefined,
3496
+ // #260: the session-guard index. Same formula as the Claude Code
3497
+ // hook. Absent on an older dist — then emitAudit still stamps origin
3498
+ // but does not write an index nobody would summarise.
3499
+ sessionGuard: typeof defenceMod.sessionKeyFor === 'function' && typeof defenceMod.appendSessionGuardIndex === 'function'
3500
+ ? {
3501
+ keyFor: (sessionId) => defenceMod.sessionKeyFor!(sessionId),
3502
+ index: (entry) => {
3503
+ defenceMod.appendSessionGuardIndex!({ entry: { ...entry } as Record<string, unknown> });
3504
+ },
3505
+ }
3506
+ : undefined,
3434
3507
  onAuditEntry: (entry) => syncInterceptEvent(entry, {
3435
3508
  cloudApiKey: (scConfig as any).cloudApiKey ?? '',
3436
3509
  cloudBaseUrl: (scConfig as any).cloudBaseUrl ?? 'https://api.shieldcortex.ai',
3437
3510
  cloudEnabled: (scConfig as any).cloudEnabled ?? false,
3438
3511
  }),
3512
+ bindAudit: typeof (defenceMod as any).attachEnforcementBinding === 'function'
3513
+ ? (entry, args) => (defenceMod as any).attachEnforcementBinding(entry, {
3514
+ plane: 'action_guard',
3515
+ hookName: 'before_tool_call',
3516
+ pluginId: 'shieldcortex-realtime',
3517
+ tool: entry.tool,
3518
+ args: args ?? {},
3519
+ }) as typeof entry
3520
+ : undefined,
3439
3521
  });
3440
3522
  const guardState = interceptorConfig.actionGuard?.enabled
3441
3523
  ? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
@@ -3497,8 +3579,9 @@ export default {
3497
3579
  // `before_tool_call`: a registered approval hook changes how OpenClaw
3498
3580
  // resolves tool-call approvals for unattended Codex agents, so an
3499
3581
  // unattended turn waited 120s on a decision nobody could give. `session_end`
3500
- // is a notification — it cannot block, approve, or delay anything — and its
3501
- // handler here only frees local state.
3582
+ // is a notification — it cannot block, approve, or delay anything. It frees
3583
+ // local state and, since #260, best-effort summarises a degraded Action
3584
+ // Guard session. That write is not a decision and cannot stall the host.
3502
3585
  try {
3503
3586
  api.on('session_end', (event?: { sessionId?: string; sessionKey?: string }, ctx?: AgentCtx) => {
3504
3587
  interceptorReady?.resetSession();
@@ -3511,11 +3594,28 @@ export default {
3511
3594
  // #233: a taint must not outlive the conversation that earned it. Same
3512
3595
  // per-session rule, for the same reason.
3513
3596
  if (endedSession) sessionTaint.clear(endedSession);
3597
+ // #260: plane-native summariser. session_end cannot block (#112) —
3598
+ // this is a notification hook. The write is best-effort and
3599
+ // idempotent with agent_end below.
3600
+ summariseGuardSession(endedSession, 'openclaw-session-end');
3514
3601
  });
3515
3602
  } catch {
3516
3603
  // session_end may not be a supported hook — TTL safety net handles this
3517
3604
  }
3518
3605
 
3606
+ // #260: agent_end exists on the engine floor (2026.5.7 already declared
3607
+ // it). An unknown typed hook is warn-and-return, not a throw, so we still
3608
+ // wrap registration. Same summariser as session_end — whichever fires
3609
+ // first writes, the other is a no-op. Do not invent a third sink.
3610
+ try {
3611
+ api.on('agent_end', (event?: { sessionId?: string; sessionKey?: string }, ctx?: AgentCtx) => {
3612
+ const endedSession = ctx?.sessionId ?? ctx?.sessionKey ?? event?.sessionId ?? event?.sessionKey ?? null;
3613
+ summariseGuardSession(endedSession, 'openclaw-session-end');
3614
+ });
3615
+ } catch {
3616
+ // Host predates agent_end — session_end is the load-bearing summariser.
3617
+ }
3618
+
3519
3619
  // llm_input/llm_output are CONVERSATION hooks: OpenClaw drops them at
3520
3620
  // registration for a non-bundled plugin unless the host grants
3521
3621
  // plugins.entries.<id>.hooks.allowConversationAccess = true. Registration is
package/interceptor.ts CHANGED
@@ -202,6 +202,17 @@ export interface InterceptAuditEntry {
202
202
  escalated?: { by: 'session-taint'; from: string; to: string; reason: string };
203
203
  /** Files the reviewed-script allowlist exempted from folding (#189). */
204
204
  reviewedScripts?: string[];
205
+ /** #260 — plane origin so the session-guard summariser can find this row. */
206
+ origin?: 'openclaw-interceptor';
207
+ sessionKey?: string;
208
+ /** #224 — binding fields. Present once the host injects `bindAudit`. */
209
+ plane?: 'action_guard' | 'conversation_firewall';
210
+ gatewayInstanceId?: string;
211
+ hookName?: string;
212
+ pluginId?: string;
213
+ nonce?: string;
214
+ seq?: number;
215
+ actionKey?: string;
205
216
  }
206
217
 
207
218
  const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'] as const;
@@ -438,6 +449,8 @@ const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string }> = [
438
449
  { re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
439
450
  // Guard's own approval store (#118): agent-side writes here mint approvals.
440
451
  { re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
452
+ // Session-lease ledger + store (#227): a freeze an agent can edit is not a freeze.
453
+ { re: /\.shieldcortex[\\/]+(?:DECISIONS\.md|leases)\b/i, signal: 'touch-decisions-ledger' },
441
454
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
442
455
  { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
443
456
  { 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' },
@@ -770,6 +783,24 @@ interface InterceptorOptions {
770
783
  * (or throwing) means no escalation — a broken scanner must never become a
771
784
  * new source of denials. */
772
785
  sessionTaint?: (sessionId: string | undefined) => { reason: string } | null;
786
+ /** #227: session action lease — injected from `shieldcortex/defence` at
787
+ * runtime (evaluateToolCallLease). Null for unscoped calls (the common
788
+ * case); a non-allow decision for a scoped call is a refusal that must
789
+ * precede every approval affordance. A THROW is treated as no-lease; state
790
+ * unreadability fails closed INSIDE the implementation. */
791
+ checkActionLease?: (
792
+ toolName: string,
793
+ args: Record<string, unknown>,
794
+ sessionId: string | undefined,
795
+ ) => {
796
+ scope: string;
797
+ decision: { verdict: string; reason: string };
798
+ acquired?: boolean;
799
+ ledgerChanged?: { fromHash: string; toHash: string };
800
+ } | null;
801
+ /** #227: release a lease this call minted early, when the guard then blocks
802
+ * the action. Best-effort; a hold self-heals at its TTL if this is absent. */
803
+ releaseActionLease?: (toolName: string, args: Record<string, unknown>, sessionId: string | undefined) => void;
773
804
  /** Approval broker (#143), injected from `shieldcortex/defence` at runtime.
774
805
  * Absent, or present with `config.enabled: false`, means no model is ever
775
806
  * consulted and the guard behaves exactly as it did before #143. */
@@ -778,6 +809,15 @@ interface InterceptorOptions {
778
809
  * limit, so a looping or compromised agent must not be able to spend it
779
810
  * without bound. Exhausting it yields no judge, which yields a hold. */
780
811
  maxJudgeCallsPerMinute?: number;
812
+ /** #260 — session-guard index. Injected from `shieldcortex/defence`. */
813
+ sessionGuard?: {
814
+ keyFor: (sessionId: string | undefined) => string | null;
815
+ index: (entry: InterceptAuditEntry) => void;
816
+ };
817
+ /** #224 — stamp plane/instance/hook/nonce/seq/actionKey before the row hits
818
+ * disk. Injected from `shieldcortex/defence` at runtime so this plugin does
819
+ * not grow a second schema. Absent = unbound (older installed package). */
820
+ bindAudit?: (entry: InterceptAuditEntry, args?: Record<string, unknown>) => InterceptAuditEntry;
781
821
  }
782
822
 
783
823
  /** How many recent tool NAMES the judge is told about. Names only, never
@@ -800,6 +840,10 @@ export function createInterceptor(
800
840
  const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
801
841
  const log = config.logger ?? { info: console.log, warn: console.warn };
802
842
  const onAuditEntry = options?.onAuditEntry;
843
+ let lastSessionId: string | undefined;
844
+ const bindAudit = options?.bindAudit;
845
+ /** Args of the in-flight tool call — used only to mint #224 actionKey. */
846
+ let lastCallArgs: Record<string, unknown> | undefined;
803
847
  const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
804
848
  const evaluateToolCall = options?.evaluateToolCall;
805
849
  const broker = options?.broker;
@@ -811,8 +855,16 @@ export function createInterceptor(
811
855
  const recentTools: string[] = [];
812
856
 
813
857
  function emitAudit(entry: InterceptAuditEntry): void {
814
- writeAuditEntry(entry);
815
- onAuditEntry?.(entry);
858
+ const sessionKey = options?.sessionGuard?.keyFor(lastSessionId) ?? undefined;
859
+ const withOrigin: InterceptAuditEntry = {
860
+ ...entry,
861
+ origin: 'openclaw-interceptor',
862
+ ...(sessionKey ? { sessionKey } : {}),
863
+ };
864
+ const bound = bindAudit ? bindAudit(withOrigin, lastCallArgs) : withOrigin;
865
+ writeAuditEntry(bound);
866
+ try { options?.sessionGuard?.index(bound); } catch { /* never wedge the turn */ }
867
+ onAuditEntry?.(bound);
816
868
  }
817
869
 
818
870
  function guardAuditBase(toolName: string, v: ToolGuardVerdictLike, preview: string): Omit<InterceptAuditEntry, 'action' | 'outcome'> {
@@ -983,6 +1035,38 @@ export function createInterceptor(
983
1035
  async function runActionGuard(context: ToolCallContext): Promise<void> {
984
1036
  if (!actionGuardCfg.enabled) return;
985
1037
 
1038
+ // Session action lease (#227) — EARLY, before the guard evaluator (which may
1039
+ // be unwired or throw). A freeze is a HARD control and must bind regardless
1040
+ // of the guard's own state; running it only after a successful evaluation
1041
+ // would let a frozen action through the guard-unavailable path. Unscoped
1042
+ // calls return null and cost nothing; a THROW is treated as no-lease (a
1043
+ // broken lease layer must not deny everything); state unreadability fails
1044
+ // closed to 'unknown' inside the store.
1045
+ let leaseGate: {
1046
+ scope: string; decision: { verdict: string; reason: string };
1047
+ acquired?: boolean; ledgerChanged?: { fromHash: string; toHash: string };
1048
+ } | null = null;
1049
+ try {
1050
+ leaseGate = options?.checkActionLease?.(context.toolName, context.arguments || {}, context.sessionId) ?? null;
1051
+ if (leaseGate?.ledgerChanged) {
1052
+ log.warn(
1053
+ `[shieldcortex] DECISIONS.md changed since last read (${leaseGate.ledgerChanged.fromHash.slice(0, 12)} → ${leaseGate.ledgerChanged.toHash.slice(0, 12)}) — tamper evidence, review the ledger`,
1054
+ );
1055
+ }
1056
+ if (leaseGate && leaseGate.decision.verdict !== 'allow') {
1057
+ emitAudit({
1058
+ ...guardAuditBase(context.toolName, { decision: 'block', severity: 'high', family: 'exec', action: `session-lease:${leaseGate.scope}`, reason: leaseGate.decision.reason, signals: ['session-lease', leaseGate.decision.verdict] } as ToolGuardVerdictLike, `${context.toolName} :: ${summariseToolArgs(context.arguments)}`),
1059
+ action: 'auto_deny', outcome: 'auto_denied',
1060
+ });
1061
+ log.warn(`[shieldcortex] action-guard SESSION-LEASE refused ${context.toolName} [${leaseGate.scope}/${leaseGate.decision.verdict}]: ${leaseGate.decision.reason}`);
1062
+ throw new Error(`ShieldCortex: tool call blocked — ${leaseGate.decision.reason}`);
1063
+ }
1064
+ } catch (err) {
1065
+ // A ShieldCortex refusal must propagate; a lease-layer malfunction must not.
1066
+ if (err instanceof Error && err.message.startsWith('ShieldCortex:')) throw err;
1067
+ leaseGate = null;
1068
+ }
1069
+
986
1070
  if (typeof evaluateToolCall !== 'function') {
987
1071
  handleGuardUnavailable(context, 'evaluateToolCall not wired');
988
1072
  return;
@@ -1065,6 +1149,11 @@ export function createInterceptor(
1065
1149
 
1066
1150
  // Catastrophic / exfil — hard block, always enforced when the guard is enabled.
1067
1151
  if (v.decision === 'block') {
1152
+ // #227: release any lease this call minted early — a blocked action must
1153
+ // not leave a hold on that scope (self-heals at TTL if release fails).
1154
+ if (leaseGate?.acquired) {
1155
+ try { options?.releaseActionLease?.(context.toolName, context.arguments || {}, context.sessionId); } catch { /* self-heals */ }
1156
+ }
1068
1157
  emitAudit({ ...base, action: 'auto_deny', outcome: 'auto_denied' });
1069
1158
  // Surface the block to the gateway log (journald). Blocks are recorded in
1070
1159
  // the ShieldCortex audit jsonl, but were otherwise invisible to an operator
@@ -1192,6 +1281,8 @@ export function createInterceptor(
1192
1281
  }
1193
1282
 
1194
1283
  async function handleToolCall(context: ToolCallContext): Promise<void> {
1284
+ lastSessionId = context.sessionId;
1285
+ lastCallArgs = context.arguments;
1195
1286
  // Remember the NAME only. This is the entirety of what the approval broker's
1196
1287
  // judge will ever learn about the session — see buildSessionSummary.
1197
1288
  noteToolForSession(context.toolName);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.49.0",
3
+ "version": "4.51.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.49.0",
3
+ "version": "4.51.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",