@drakon-systems/shieldcortex-realtime 4.54.8 → 4.54.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2764,6 +2764,12 @@ function handleLlmOutput(event, ctx) {
2764
2764
  }
2765
2765
  class TypedApprovalRequest extends Error {
2766
2766
  request;
2767
+ /** #372 — one-shot audit writer the interceptor hangs on this error at hold
2768
+ * time, carrying the guard's audit base and the session the hold belongs to.
2769
+ * Both mint sites attach it (action-guard and memory-write pipelines).
2770
+ * Absent only for an older installed interceptor: then no `onResolution`
2771
+ * is wired at all and the card behaves exactly as it did before #372. */
2772
+ decisionAudit;
2767
2773
  constructor(message, request) {
2768
2774
  super(message);
2769
2775
  this.name = "TypedApprovalRequest";
@@ -2819,6 +2825,26 @@ function buildTypedApprovalRequest(message) {
2819
2825
  };
2820
2826
  }
2821
2827
  export const __buildTypedApprovalRequestForTest = buildTypedApprovalRequest;
2828
+ /** #372 — the operator's answer, as the audit stream records it.
2829
+ *
2830
+ * `allow-always` is defensive: today's card only offers allow-once/deny (see
2831
+ * `allowedDecisions`), but a host that grows the button must not be able to
2832
+ * produce an approval nobody can find afterwards. It maps to `approved_once`
2833
+ * rather than a sticky outcome because ShieldCortex grants nothing durable
2834
+ * here — the next call is gated again. */
2835
+ /** The decision label as it may appear in a gateway log line. Host-supplied,
2836
+ * so it is reduced to an identifier shape first — a log line is not a place to
2837
+ * render whatever a caller passed. */
2838
+ function safeDecisionLabel(decision) {
2839
+ return String(decision).replace(/[^A-Za-z0-9_.:-]/gu, "?").slice(0, 32) || "unknown";
2840
+ }
2841
+ const CARD_DECISION_OUTCOME = {
2842
+ "allow-once": "approved_once",
2843
+ "allow-always": "approved_once",
2844
+ deny: "card_denied",
2845
+ timeout: "card_timeout",
2846
+ cancelled: "card_cancelled",
2847
+ };
2822
2848
  async function handleTypedBeforeToolCall(event, interceptor, logger, ctx) {
2823
2849
  const sessionId = resolveHookSessionId(event, ctx);
2824
2850
  // #310: a card is only worth minting when a human is there to tap it. Cron
@@ -2844,6 +2870,28 @@ async function handleTypedBeforeToolCall(event, interceptor, logger, ctx) {
2844
2870
  }
2845
2871
  catch (err) {
2846
2872
  if (err instanceof TypedApprovalRequest) {
2873
+ const decisionAudit = err.decisionAudit;
2874
+ if (decisionAudit) {
2875
+ // #372: the hold writes no row because no decision exists yet — THIS is
2876
+ // where the operator's answer becomes one. The host owns this callback
2877
+ // and awaits it, so it must never see a throw: a broken audit sink is
2878
+ // not a reason to disturb a decision the operator already made.
2879
+ err.request.onResolution = (decision) => {
2880
+ try {
2881
+ const outcome = Object.hasOwn(CARD_DECISION_OUTCOME, decision) ? CARD_DECISION_OUTCOME[decision] : undefined;
2882
+ if (!outcome) {
2883
+ // A decision this build does not know. Say so loudly rather than
2884
+ // guess — inventing an outcome would forge the operator's answer.
2885
+ logger?.warn?.(`[shieldcortex] unrecognised approval decision '${safeDecisionLabel(decision)}' — no audit row written`);
2886
+ return;
2887
+ }
2888
+ decisionAudit(outcome);
2889
+ }
2890
+ catch (auditErr) {
2891
+ logger?.warn?.(`[shieldcortex] approval decision audit failed (${safeDecisionLabel(decision)}): ${auditErr instanceof Error ? auditErr.message : auditErr}`);
2892
+ }
2893
+ };
2894
+ }
2847
2895
  return { requireApproval: err.request };
2848
2896
  }
2849
2897
  if (err instanceof Error && err.message.startsWith("ShieldCortex:")) {
@@ -4,6 +4,11 @@ import { join, isAbsolute, resolve as resolvePath } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { createGatewayInvoker } from './broker-invoker.js';
6
6
  import { escalateForTaint } from './session-taint.js';
7
+ /** #372 — the runtime mirror of ApprovalDecisionOutcome: the closure crosses a
8
+ * plugin boundary, so the union is enforced with a Set, not just the compiler. */
9
+ const CARD_AUDIT_OUTCOMES = new Set([
10
+ 'approved_once', 'card_denied', 'card_timeout', 'card_cancelled',
11
+ ]);
7
12
  const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'];
8
13
  const CONTENT_FIELDS = {
9
14
  remember: ['content', 'title'],
@@ -538,14 +543,16 @@ export function createInterceptor(config, pipeline, options) {
538
543
  const judgeLimiter = new RateLimiter(options?.maxJudgeCallsPerMinute ?? 20);
539
544
  /** Bare tool names seen this session, newest last. See buildSessionSummary. */
540
545
  const recentTools = [];
541
- function emitAudit(entry) {
542
- const sessionKey = options?.sessionGuard?.keyFor(lastSessionId) ?? undefined;
546
+ /** The one write path every intercept row takes. `captured` is normally the
547
+ * in-flight call (emitAudit below); #372 hands it a hold-time snapshot so a
548
+ * decision that arrives after the turn moved on still lands on ITS call. */
549
+ function emitAuditWith(entry, captured) {
543
550
  const withOrigin = {
544
551
  ...entry,
545
552
  origin: 'openclaw-interceptor',
546
- ...(sessionKey ? { sessionKey } : {}),
553
+ ...(captured.sessionKey ? { sessionKey: captured.sessionKey } : {}),
547
554
  };
548
- const bound = bindAudit ? bindAudit(withOrigin, lastCallArgs) : withOrigin;
555
+ const bound = bindAudit ? bindAudit(withOrigin, captured.args) : withOrigin;
549
556
  writeAuditEntry(bound);
550
557
  try {
551
558
  options?.sessionGuard?.index(bound);
@@ -553,6 +560,62 @@ export function createInterceptor(config, pipeline, options) {
553
560
  catch { /* never wedge the turn */ }
554
561
  onAuditEntry?.(bound);
555
562
  }
563
+ function emitAudit(entry) {
564
+ emitAuditWith(entry, {
565
+ sessionKey: options?.sessionGuard?.keyFor(lastSessionId) ?? undefined,
566
+ args: lastCallArgs,
567
+ });
568
+ }
569
+ /**
570
+ * #372 — hang a one-shot decision writer on a minted approval card.
571
+ *
572
+ * The hold itself is still unaudited by design: no decision exists yet. What
573
+ * was missing is the other end — the host reports the operator's answer on
574
+ * the request's `onResolution`, and nothing on that path knew what the guard
575
+ * saw, so an operator-APPROVED dangerous action left no intercept row at all
576
+ * (invisible to the #260 session summaries).
577
+ *
578
+ * Everything the row needs is captured HERE: the guard's audit base, the
579
+ * session key, and the args behind the #224 actionKey. Nothing about the
580
+ * approval prompt is retained — the preview is the guard's own already-bounded
581
+ * one, so the secret-egress discipline the card copy follows holds here too.
582
+ */
583
+ function attachDecisionAudit(err, auditBase) {
584
+ let sessionKey;
585
+ // Resolving the key is new work on the card path — nothing used to call
586
+ // keyFor here. A throwing resolver costs the row its key; it must never
587
+ // turn a mintable card into an approval error.
588
+ try {
589
+ sessionKey = options?.sessionGuard?.keyFor(lastSessionId) ?? undefined;
590
+ }
591
+ catch { /* unkeyed row */ }
592
+ // Shallow-snapshot the args: reference capture would let in-place mutation
593
+ // of the params object between hold and resolution rewrite the one
594
+ // forensic binding this row exists to protect (review nit, both reviewers).
595
+ const captured = { sessionKey, args: lastCallArgs ? { ...lastCallArgs } : undefined };
596
+ const held = { ...auditBase };
597
+ let written = false;
598
+ err.decisionAudit = (outcome) => {
599
+ // The closure's type says CardAuditOutcome, but it crosses a plugin
600
+ // boundary — make the union real at runtime rather than trusting the
601
+ // caller's compiler (defence-in-depth, both reviewers).
602
+ if (!CARD_AUDIT_OUTCOMES.has(outcome))
603
+ return;
604
+ // The host resolves a card once. Enforcing it here is cheaper than
605
+ // trusting it: a duplicate row would double-count in every summary.
606
+ if (written)
607
+ return;
608
+ written = true;
609
+ // Never throws, by contract — even if the audit plumbing does. The latch
610
+ // is consumed either way: never-double-write beats a retried row.
611
+ try {
612
+ // ts is the DECISION time; the hold time stays in heldAtTs so
613
+ // forensics can see both ends of the operator's think.
614
+ emitAuditWith({ ...held, heldAtTs: held.ts, ts: new Date().toISOString(), action: 'require_approval', outcome }, captured);
615
+ }
616
+ catch { /* audit is best-effort; the decision itself already happened */ }
617
+ };
618
+ }
556
619
  function guardAuditBase(toolName, v, preview) {
557
620
  return {
558
621
  type: 'intercept', tool: toolName,
@@ -931,8 +994,12 @@ export function createInterceptor(config, pipeline, options) {
931
994
  // #310: a minted approval card, not an error. Re-thrown untouched so the
932
995
  // typed-hook bridge can turn it into the operator's card; auditing it
933
996
  // here would write a denial for a decision nobody has made yet.
934
- if (isTypedApprovalRequest(err))
997
+ if (isTypedApprovalRequest(err)) {
998
+ // #372: still no row for the hold — but the card leaves carrying the
999
+ // closure that writes one the moment the operator answers.
1000
+ attachDecisionAudit(err, auditBase);
935
1001
  throw err;
1002
+ }
936
1003
  if (brokered && err instanceof ApprovalTimeout) {
937
1004
  // The asymmetric path. Silence is only ever a yes for something the
938
1005
  // broker already pre-cleared — and that returned long before here — so
@@ -1103,8 +1170,17 @@ export function createInterceptor(config, pipeline, options) {
1103
1170
  catch (err) {
1104
1171
  // #310: same bridge, same rule — the card request is not an approval
1105
1172
  // failure. Everything else below stays fail-closed.
1106
- if (isTypedApprovalRequest(err))
1173
+ // #372: this path (memory-write pipeline) mints cards too — its
1174
+ // operator decision must leave the same audit row as the action-guard
1175
+ // lane, captured at hold time for the same attribution reasons.
1176
+ if (isTypedApprovalRequest(err)) {
1177
+ attachDecisionAudit(err, {
1178
+ type: 'intercept', tool: context.toolName, severity, firewallResult,
1179
+ threats, anomalyScore, trustScore, sensitivityLevel, fragmentationScore, pipelineDurationMs,
1180
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
1181
+ });
1107
1182
  throw err;
1183
+ }
1108
1184
  const failAction = config.failurePolicy[severity];
1109
1185
  log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
1110
1186
  const entry = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.54.8",
3
+ "version": "4.54.9",
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,
@@ -121,7 +121,7 @@
121
121
  },
122
122
  "interceptor.conversation.posture": {
123
123
  "label": "Conversation Firewall",
124
- "description": "What the conversation firewall does with a detection on the input path. off = do not scan; observe = scan, audit and alert the operator but never stop the turn (default); enforce = block the run via before_agent_run. Requires plugins.entries.shieldcortex-realtime.hooks.allowConversationAccess=true on this host \u2014 OpenClaw refuses conversation hooks without that operator grant, and ShieldCortex will never set it for you.",
124
+ "description": "What the conversation firewall does with a detection on the input path. off = do not scan; observe = scan, audit and alert the operator but never stop the turn (default); enforce = block the run via before_agent_run. Requires plugins.entries.shieldcortex-realtime.hooks.allowConversationAccess=true on this host — OpenClaw refuses conversation hooks without that operator grant, and ShieldCortex will never set it for you.",
125
125
  "type": "string"
126
126
  },
127
127
  "interceptor.actionGuard.notify.enabled": {
@@ -132,7 +132,7 @@
132
132
  },
133
133
  "interceptor.actionGuard.notify.webhookUrl": {
134
134
  "label": "Notify Webhook URL",
135
- "description": "http(s) endpoint the notification is POSTed to. Conversation-firewall alerts carry no approve/deny affordance \u2014 there is nothing to approve.",
135
+ "description": "http(s) endpoint the notification is POSTed to. Conversation-firewall alerts carry no approve/deny affordance — there is nothing to approve.",
136
136
  "type": "string",
137
137
  "advanced": true
138
138
  },
@@ -156,7 +156,7 @@
156
156
  "properties": {
157
157
  "enabled": {
158
158
  "type": "boolean",
159
- "description": "Unused \u2014 plugin on/off is controlled by plugins.entries[id].enabled on the host, not this nested config value. See #115."
159
+ "description": "Unused — plugin on/off is controlled by plugins.entries[id].enabled on the host, not this nested config value. See #115."
160
160
  },
161
161
  "binaryPath": {
162
162
  "type": "string"
@@ -190,7 +190,7 @@
190
190
  "trustOwnerInput": {
191
191
  "type": "boolean",
192
192
  "default": true,
193
- "description": "Default true: a message the host attributes to the gateway OWNER is an instruction, so a detection in it is audited and alerted but never taints the session or blocks the turn. Set false on a host where the owner routinely pastes untrusted content and you would rather have the caution than the quiet. Content from anyone else \u2014 including another agent on a trusted channel \u2014 is data regardless of this setting."
193
+ "description": "Default true: a message the host attributes to the gateway OWNER is an instruction, so a detection in it is audited and alerted but never taints the session or blocks the turn. Set false on a host where the owner routinely pastes untrusted content and you would rather have the caution than the quiet. Content from anyone else — including another agent on a trusted channel — is data regardless of this setting."
194
194
  }
195
195
  }
196
196
  },
@@ -368,7 +368,7 @@
368
368
  "notify": {
369
369
  "type": "object",
370
370
  "additionalProperties": false,
371
- "description": "Operator-notify transport (#143), also used by the conversation firewall\u2019s detection sink (#225). Off unless enabled is exactly true.",
371
+ "description": "Operator-notify transport (#143), also used by the conversation firewall’s detection sink (#225). Off unless enabled is exactly true.",
372
372
  "properties": {
373
373
  "enabled": {
374
374
  "type": "boolean",
@@ -491,7 +491,7 @@
491
491
  "notify": {
492
492
  "type": "object",
493
493
  "additionalProperties": false,
494
- "description": "Operator-notify transport (#143), also used by the conversation firewall\u2019s detection sink (#225). Off unless enabled is exactly true.",
494
+ "description": "Operator-notify transport (#143), also used by the conversation firewall’s detection sink (#225). Off unless enabled is exactly true.",
495
495
  "properties": {
496
496
  "enabled": {
497
497
  "type": "boolean",
package/index.ts CHANGED
@@ -38,7 +38,7 @@ import { isTaintingScanSummary, severityFromScanSummary } from './scan-taint-pol
38
38
  import { classifyConversationOrigin } from './conversation-trust.js';
39
39
  import type { ConversationTrustDecision } from './conversation-trust.js';
40
40
  import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
41
- import type { InterceptorConfig, BrokerRuntime } from './interceptor.js';
41
+ import type { ApprovalDecisionAudit, ApprovalDecisionOutcome, InterceptorConfig, BrokerRuntime } from './interceptor.js';
42
42
  import { syncInterceptEvent } from './intercept-ingest.js';
43
43
  import { cloudSync } from './cloud-sync.js';
44
44
  import { createGatewayNotifyChannel } from './gateway-notify-channel.js';
@@ -405,6 +405,10 @@ type TypedBeforeToolCallEvent = {
405
405
  // send one, and every other hook's event carries it.
406
406
  sessionId?: string;
407
407
  };
408
+ /** Every answer the host can report for a minted card. The first three are
409
+ * buttons (a subset of which the card offers via `allowedDecisions`); the last
410
+ * two are the host closing the card without one. */
411
+ type CardDecision = "allow-once" | "allow-always" | "deny" | "timeout" | "cancelled";
408
412
  type TypedBeforeToolCallResult = {
409
413
  block?: boolean;
410
414
  blockReason?: string;
@@ -414,8 +418,8 @@ type TypedBeforeToolCallResult = {
414
418
  severity?: "info" | "warning" | "critical";
415
419
  timeoutMs?: number;
416
420
  timeoutBehavior?: "allow" | "deny";
417
- allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
418
- onResolution?: (decision: "allow-once" | "allow-always" | "deny" | "timeout" | "cancelled") => Promise<void> | void;
421
+ allowedDecisions?: Array<Extract<CardDecision, "allow-once" | "allow-always" | "deny">>;
422
+ onResolution?: (decision: CardDecision) => Promise<void> | void;
419
423
  };
420
424
  };
421
425
  type PluginApi = {
@@ -3292,6 +3296,12 @@ function handleLlmOutput(event: LlmOutputEvent, ctx: AgentCtx): void {
3292
3296
 
3293
3297
  class TypedApprovalRequest extends Error {
3294
3298
  request: NonNullable<TypedBeforeToolCallResult["requireApproval"]>;
3299
+ /** #372 — one-shot audit writer the interceptor hangs on this error at hold
3300
+ * time, carrying the guard's audit base and the session the hold belongs to.
3301
+ * Both mint sites attach it (action-guard and memory-write pipelines).
3302
+ * Absent only for an older installed interceptor: then no `onResolution`
3303
+ * is wired at all and the card behaves exactly as it did before #372. */
3304
+ decisionAudit?: ApprovalDecisionAudit;
3295
3305
 
3296
3306
  constructor(message: string, request: NonNullable<TypedBeforeToolCallResult["requireApproval"]>) {
3297
3307
  super(message);
@@ -3355,6 +3365,28 @@ function buildTypedApprovalRequest(message: string): NonNullable<TypedBeforeTool
3355
3365
 
3356
3366
  export const __buildTypedApprovalRequestForTest = buildTypedApprovalRequest;
3357
3367
 
3368
+ /** #372 — the operator's answer, as the audit stream records it.
3369
+ *
3370
+ * `allow-always` is defensive: today's card only offers allow-once/deny (see
3371
+ * `allowedDecisions`), but a host that grows the button must not be able to
3372
+ * produce an approval nobody can find afterwards. It maps to `approved_once`
3373
+ * rather than a sticky outcome because ShieldCortex grants nothing durable
3374
+ * here — the next call is gated again. */
3375
+ /** The decision label as it may appear in a gateway log line. Host-supplied,
3376
+ * so it is reduced to an identifier shape first — a log line is not a place to
3377
+ * render whatever a caller passed. */
3378
+ function safeDecisionLabel(decision: unknown): string {
3379
+ return String(decision).replace(/[^A-Za-z0-9_.:-]/gu, "?").slice(0, 32) || "unknown";
3380
+ }
3381
+
3382
+ const CARD_DECISION_OUTCOME: Record<CardDecision, ApprovalDecisionOutcome> = {
3383
+ "allow-once": "approved_once",
3384
+ "allow-always": "approved_once",
3385
+ deny: "card_denied",
3386
+ timeout: "card_timeout",
3387
+ cancelled: "card_cancelled",
3388
+ };
3389
+
3358
3390
  async function handleTypedBeforeToolCall(
3359
3391
  event: TypedBeforeToolCallEvent,
3360
3392
  interceptor: ReturnType<typeof createInterceptor>,
@@ -3384,6 +3416,27 @@ async function handleTypedBeforeToolCall(
3384
3416
  });
3385
3417
  } catch (err) {
3386
3418
  if (err instanceof TypedApprovalRequest) {
3419
+ const decisionAudit = err.decisionAudit;
3420
+ if (decisionAudit) {
3421
+ // #372: the hold writes no row because no decision exists yet — THIS is
3422
+ // where the operator's answer becomes one. The host owns this callback
3423
+ // and awaits it, so it must never see a throw: a broken audit sink is
3424
+ // not a reason to disturb a decision the operator already made.
3425
+ err.request.onResolution = (decision: CardDecision) => {
3426
+ try {
3427
+ const outcome = Object.hasOwn(CARD_DECISION_OUTCOME, decision) ? CARD_DECISION_OUTCOME[decision] : undefined;
3428
+ if (!outcome) {
3429
+ // A decision this build does not know. Say so loudly rather than
3430
+ // guess — inventing an outcome would forge the operator's answer.
3431
+ (logger as any)?.warn?.(`[shieldcortex] unrecognised approval decision '${safeDecisionLabel(decision)}' — no audit row written`);
3432
+ return;
3433
+ }
3434
+ decisionAudit(outcome);
3435
+ } catch (auditErr) {
3436
+ (logger as any)?.warn?.(`[shieldcortex] approval decision audit failed (${safeDecisionLabel(decision)}): ${auditErr instanceof Error ? auditErr.message : auditErr}`);
3437
+ }
3438
+ };
3439
+ }
3387
3440
  return { requireApproval: err.request };
3388
3441
  }
3389
3442
 
package/interceptor.ts CHANGED
@@ -198,9 +198,55 @@ export interface ToolCallContext {
198
198
  sessionId?: string;
199
199
  }
200
200
 
201
+ /**
202
+ * #372 — outcomes only an OpenClaw-native approval card can produce.
203
+ *
204
+ * The card is minted by throwing (see isTypedApprovalRequest) and the
205
+ * operator's answer comes back through the host minutes later, long after the
206
+ * hook returned. These outcomes are deliberately distinct from the synchronous
207
+ * `approved`/`denied` pair: a row that says `approved_once` is a human tapping
208
+ * a button on a card, not an approver function returning true inside the turn.
209
+ */
210
+ export type ApprovalDecisionOutcome =
211
+ | 'approved_once'
212
+ | 'card_denied'
213
+ | 'card_timeout'
214
+ | 'card_cancelled';
215
+
216
+ /** #372 — the runtime mirror of ApprovalDecisionOutcome: the closure crosses a
217
+ * plugin boundary, so the union is enforced with a Set, not just the compiler. */
218
+ const CARD_AUDIT_OUTCOMES: ReadonlySet<string> = new Set([
219
+ 'approved_once', 'card_denied', 'card_timeout', 'card_cancelled',
220
+ ]);
221
+
222
+ /** #372 — one-shot writer for the decision a held card eventually receives.
223
+ * Hung on the thrown approval request by the interceptor; invoked by the
224
+ * plugin bridge from the host's `onResolution`. Never throws. */
225
+ export type ApprovalDecisionAudit = (outcome: ApprovalDecisionOutcome) => void;
226
+
227
+ /** Structural view of the plugin's TypedApprovalRequest error. Matched by
228
+ * shape and never imported — the same compile-time-independence discipline as
229
+ * isTypedApprovalRequest and ToolGuardVerdictLike. */
230
+ interface DecisionAuditCarrier {
231
+ decisionAudit?: ApprovalDecisionAudit;
232
+ }
233
+
234
+ /** #372 — what an audit row needs from the tool call that produced it,
235
+ * snapshotted at hold time. A card decision lands minutes later, by which
236
+ * point the interceptor's live `lastSessionId`/`lastCallArgs` may describe a
237
+ * completely different call — attributing the decision to THAT call would be
238
+ * a forgery in exactly the record forensics trusts. */
239
+ interface CapturedAuditContext {
240
+ sessionKey?: string;
241
+ args?: Record<string, unknown>;
242
+ }
243
+
201
244
  export interface InterceptAuditEntry {
202
245
  type: 'intercept';
203
246
  tool: string;
247
+ /** #372 — card decision rows only: when the action was HELD. `ts` on those
248
+ * rows is the operator's decision time; the pair bounds the wait. */
249
+ heldAtTs?: string;
204
250
  severity: Severity;
205
251
  firewallResult: string;
206
252
  threats: string[];
@@ -210,7 +256,10 @@ export interface InterceptAuditEntry {
210
256
  fragmentationScore: number | null; // from the pipeline result's fragmentation score, or null
211
257
  pipelineDurationMs: number; // wall-clock ms around the runDefencePipeline call
212
258
  action: InterceptAction | 'auto_deny' | 'rate_limit' | 'allow' | 'gate_degraded';
213
- outcome: 'approved' | 'denied' | 'auto_denied' | 'logged' | 'warned' | 'failure_allowed' | 'failure_denied' | 'allowed';
259
+ outcome: 'approved' | 'denied' | 'auto_denied' | 'logged' | 'warned' | 'failure_allowed' | 'failure_denied' | 'allowed'
260
+ // #372 — card-held decisions, written when the operator answers rather than
261
+ // when the hold is taken. `action` for these rows is 'require_approval'.
262
+ | ApprovalDecisionOutcome;
214
263
  preview: string;
215
264
  ts: string;
216
265
  /** The approval broker's record for this call (#143). Present on exactly the
@@ -560,7 +609,7 @@ export function formatActionGuardPrompt(toolName: string, v: ToolGuardVerdictLik
560
609
  * error is exactly what turned every native card into a `failure_denied` the
561
610
  * operator never saw.
562
611
  */
563
- function isTypedApprovalRequest(err: unknown): boolean {
612
+ function isTypedApprovalRequest(err: unknown): err is Error & DecisionAuditCarrier {
564
613
  return err instanceof Error && err.name === 'TypedApprovalRequest';
565
614
  }
566
615
 
@@ -907,19 +956,79 @@ export function createInterceptor(
907
956
  /** Bare tool names seen this session, newest last. See buildSessionSummary. */
908
957
  const recentTools: string[] = [];
909
958
 
910
- function emitAudit(entry: InterceptAuditEntry): void {
911
- const sessionKey = options?.sessionGuard?.keyFor(lastSessionId) ?? undefined;
959
+ /** The one write path every intercept row takes. `captured` is normally the
960
+ * in-flight call (emitAudit below); #372 hands it a hold-time snapshot so a
961
+ * decision that arrives after the turn moved on still lands on ITS call. */
962
+ function emitAuditWith(entry: InterceptAuditEntry, captured: CapturedAuditContext): void {
912
963
  const withOrigin: InterceptAuditEntry = {
913
964
  ...entry,
914
965
  origin: 'openclaw-interceptor',
915
- ...(sessionKey ? { sessionKey } : {}),
966
+ ...(captured.sessionKey ? { sessionKey: captured.sessionKey } : {}),
916
967
  };
917
- const bound = bindAudit ? bindAudit(withOrigin, lastCallArgs) : withOrigin;
968
+ const bound = bindAudit ? bindAudit(withOrigin, captured.args) : withOrigin;
918
969
  writeAuditEntry(bound);
919
970
  try { options?.sessionGuard?.index(bound); } catch { /* never wedge the turn */ }
920
971
  onAuditEntry?.(bound);
921
972
  }
922
973
 
974
+ function emitAudit(entry: InterceptAuditEntry): void {
975
+ emitAuditWith(entry, {
976
+ sessionKey: options?.sessionGuard?.keyFor(lastSessionId) ?? undefined,
977
+ args: lastCallArgs,
978
+ });
979
+ }
980
+
981
+ /**
982
+ * #372 — hang a one-shot decision writer on a minted approval card.
983
+ *
984
+ * The hold itself is still unaudited by design: no decision exists yet. What
985
+ * was missing is the other end — the host reports the operator's answer on
986
+ * the request's `onResolution`, and nothing on that path knew what the guard
987
+ * saw, so an operator-APPROVED dangerous action left no intercept row at all
988
+ * (invisible to the #260 session summaries).
989
+ *
990
+ * Everything the row needs is captured HERE: the guard's audit base, the
991
+ * session key, and the args behind the #224 actionKey. Nothing about the
992
+ * approval prompt is retained — the preview is the guard's own already-bounded
993
+ * one, so the secret-egress discipline the card copy follows holds here too.
994
+ */
995
+ function attachDecisionAudit(
996
+ err: Error & DecisionAuditCarrier,
997
+ auditBase: Omit<InterceptAuditEntry, 'action' | 'outcome'>,
998
+ ): void {
999
+ let sessionKey: string | undefined;
1000
+ // Resolving the key is new work on the card path — nothing used to call
1001
+ // keyFor here. A throwing resolver costs the row its key; it must never
1002
+ // turn a mintable card into an approval error.
1003
+ try { sessionKey = options?.sessionGuard?.keyFor(lastSessionId) ?? undefined; } catch { /* unkeyed row */ }
1004
+ // Shallow-snapshot the args: reference capture would let in-place mutation
1005
+ // of the params object between hold and resolution rewrite the one
1006
+ // forensic binding this row exists to protect (review nit, both reviewers).
1007
+ const captured: CapturedAuditContext = { sessionKey, args: lastCallArgs ? { ...lastCallArgs } : undefined };
1008
+ const held: Omit<InterceptAuditEntry, 'action' | 'outcome'> = { ...auditBase };
1009
+ let written = false;
1010
+ err.decisionAudit = (outcome) => {
1011
+ // The closure's type says CardAuditOutcome, but it crosses a plugin
1012
+ // boundary — make the union real at runtime rather than trusting the
1013
+ // caller's compiler (defence-in-depth, both reviewers).
1014
+ if (!CARD_AUDIT_OUTCOMES.has(outcome)) return;
1015
+ // The host resolves a card once. Enforcing it here is cheaper than
1016
+ // trusting it: a duplicate row would double-count in every summary.
1017
+ if (written) return;
1018
+ written = true;
1019
+ // Never throws, by contract — even if the audit plumbing does. The latch
1020
+ // is consumed either way: never-double-write beats a retried row.
1021
+ try {
1022
+ // ts is the DECISION time; the hold time stays in heldAtTs so
1023
+ // forensics can see both ends of the operator's think.
1024
+ emitAuditWith(
1025
+ { ...held, heldAtTs: held.ts, ts: new Date().toISOString(), action: 'require_approval', outcome },
1026
+ captured,
1027
+ );
1028
+ } catch { /* audit is best-effort; the decision itself already happened */ }
1029
+ };
1030
+ }
1031
+
923
1032
  function guardAuditBase(toolName: string, v: ToolGuardVerdictLike, preview: string): Omit<InterceptAuditEntry, 'action' | 'outcome'> {
924
1033
  return {
925
1034
  type: 'intercept', tool: toolName,
@@ -1317,7 +1426,12 @@ export function createInterceptor(
1317
1426
  // #310: a minted approval card, not an error. Re-thrown untouched so the
1318
1427
  // typed-hook bridge can turn it into the operator's card; auditing it
1319
1428
  // here would write a denial for a decision nobody has made yet.
1320
- if (isTypedApprovalRequest(err)) throw err;
1429
+ if (isTypedApprovalRequest(err)) {
1430
+ // #372: still no row for the hold — but the card leaves carrying the
1431
+ // closure that writes one the moment the operator answers.
1432
+ attachDecisionAudit(err, auditBase);
1433
+ throw err;
1434
+ }
1321
1435
  if (brokered && err instanceof ApprovalTimeout) {
1322
1436
  // The asymmetric path. Silence is only ever a yes for something the
1323
1437
  // broker already pre-cleared — and that returned long before here — so
@@ -1500,7 +1614,17 @@ export function createInterceptor(
1500
1614
  } catch (err) {
1501
1615
  // #310: same bridge, same rule — the card request is not an approval
1502
1616
  // failure. Everything else below stays fail-closed.
1503
- if (isTypedApprovalRequest(err)) throw err;
1617
+ // #372: this path (memory-write pipeline) mints cards too — its
1618
+ // operator decision must leave the same audit row as the action-guard
1619
+ // lane, captured at hold time for the same attribution reasons.
1620
+ if (isTypedApprovalRequest(err)) {
1621
+ attachDecisionAudit(err, {
1622
+ type: 'intercept', tool: context.toolName, severity, firewallResult,
1623
+ threats, anomalyScore, trustScore, sensitivityLevel, fragmentationScore, pipelineDurationMs,
1624
+ preview: fullContent.slice(0, 200), ts: new Date().toISOString(),
1625
+ });
1626
+ throw err;
1627
+ }
1504
1628
  const failAction = config.failurePolicy[severity];
1505
1629
  log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
1506
1630
  const entry: InterceptAuditEntry = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.54.8",
3
+ "version": "4.54.9",
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,
@@ -121,7 +121,7 @@
121
121
  },
122
122
  "interceptor.conversation.posture": {
123
123
  "label": "Conversation Firewall",
124
- "description": "What the conversation firewall does with a detection on the input path. off = do not scan; observe = scan, audit and alert the operator but never stop the turn (default); enforce = block the run via before_agent_run. Requires plugins.entries.shieldcortex-realtime.hooks.allowConversationAccess=true on this host \u2014 OpenClaw refuses conversation hooks without that operator grant, and ShieldCortex will never set it for you.",
124
+ "description": "What the conversation firewall does with a detection on the input path. off = do not scan; observe = scan, audit and alert the operator but never stop the turn (default); enforce = block the run via before_agent_run. Requires plugins.entries.shieldcortex-realtime.hooks.allowConversationAccess=true on this host — OpenClaw refuses conversation hooks without that operator grant, and ShieldCortex will never set it for you.",
125
125
  "type": "string"
126
126
  },
127
127
  "interceptor.actionGuard.notify.enabled": {
@@ -132,7 +132,7 @@
132
132
  },
133
133
  "interceptor.actionGuard.notify.webhookUrl": {
134
134
  "label": "Notify Webhook URL",
135
- "description": "http(s) endpoint the notification is POSTed to. Conversation-firewall alerts carry no approve/deny affordance \u2014 there is nothing to approve.",
135
+ "description": "http(s) endpoint the notification is POSTed to. Conversation-firewall alerts carry no approve/deny affordance — there is nothing to approve.",
136
136
  "type": "string",
137
137
  "advanced": true
138
138
  },
@@ -156,7 +156,7 @@
156
156
  "properties": {
157
157
  "enabled": {
158
158
  "type": "boolean",
159
- "description": "Unused \u2014 plugin on/off is controlled by plugins.entries[id].enabled on the host, not this nested config value. See #115."
159
+ "description": "Unused — plugin on/off is controlled by plugins.entries[id].enabled on the host, not this nested config value. See #115."
160
160
  },
161
161
  "binaryPath": {
162
162
  "type": "string"
@@ -190,7 +190,7 @@
190
190
  "trustOwnerInput": {
191
191
  "type": "boolean",
192
192
  "default": true,
193
- "description": "Default true: a message the host attributes to the gateway OWNER is an instruction, so a detection in it is audited and alerted but never taints the session or blocks the turn. Set false on a host where the owner routinely pastes untrusted content and you would rather have the caution than the quiet. Content from anyone else \u2014 including another agent on a trusted channel \u2014 is data regardless of this setting."
193
+ "description": "Default true: a message the host attributes to the gateway OWNER is an instruction, so a detection in it is audited and alerted but never taints the session or blocks the turn. Set false on a host where the owner routinely pastes untrusted content and you would rather have the caution than the quiet. Content from anyone else — including another agent on a trusted channel — is data regardless of this setting."
194
194
  }
195
195
  }
196
196
  },
@@ -368,7 +368,7 @@
368
368
  "notify": {
369
369
  "type": "object",
370
370
  "additionalProperties": false,
371
- "description": "Operator-notify transport (#143), also used by the conversation firewall\u2019s detection sink (#225). Off unless enabled is exactly true.",
371
+ "description": "Operator-notify transport (#143), also used by the conversation firewall’s detection sink (#225). Off unless enabled is exactly true.",
372
372
  "properties": {
373
373
  "enabled": {
374
374
  "type": "boolean",
@@ -491,7 +491,7 @@
491
491
  "notify": {
492
492
  "type": "object",
493
493
  "additionalProperties": false,
494
- "description": "Operator-notify transport (#143), also used by the conversation firewall\u2019s detection sink (#225). Off unless enabled is exactly true.",
494
+ "description": "Operator-notify transport (#143), also used by the conversation firewall’s detection sink (#225). Off unless enabled is exactly true.",
495
495
  "properties": {
496
496
  "enabled": {
497
497
  "type": "boolean",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.54.8",
3
+ "version": "4.54.9",
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",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "pack:verify": "npm pack --dry-run",
27
- "prepublishOnly": "node -e \"if(!require('fs').existsSync('dist/index.js'))throw new Error('plugin dist/index.js missing \u2014 run `npm run build:ts` from the repo root before publishing')\""
27
+ "prepublishOnly": "node -e \"if(!require('fs').existsSync('dist/index.js'))throw new Error('plugin dist/index.js missing — run `npm run build:ts` from the repo root before publishing')\""
28
28
  },
29
29
  "peerDependencies": {
30
30
  "shieldcortex": "^4.54.8",