@drakon-systems/shieldcortex-realtime 4.54.7 → 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";
@@ -2776,6 +2782,18 @@ function truncateApprovalText(text, maxLength) {
2776
2782
  return normalized;
2777
2783
  return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
2778
2784
  }
2785
+ /** A held command can itself contain the credential that tripped the guard, and
2786
+ * an approval card is a chat surface. Same rule, same phrasing and same
2787
+ * trade as the Telegram card path (isSecretEgress / buildCardFields in
2788
+ * src/defence/iron-dome/openclaw-approval-channel.ts): the operator keeps the
2789
+ * tool, the signals and the severity, and loses exactly the text that must not
2790
+ * be forwarded. */
2791
+ const SECRET_EGRESS_PROMPT = /secret|credential/iu;
2792
+ const WITHHELD_COMMAND_TEXT = "(command withheld — contains credential material)";
2793
+ /** Prompt lines safe to forward on a secret-egress hold — label-only metadata.
2794
+ * `Reason:` (action guard) and `Content:` (memory write) are the two lines
2795
+ * that quote the payload, so anything not on this list is dropped. */
2796
+ const SAFE_APPROVAL_LINE = /^(?:Tool|Action|Risk|Signals|Threats):/iu;
2779
2797
  function buildTypedApprovalRequest(message) {
2780
2798
  const lines = message
2781
2799
  .split(/\r?\n/u)
@@ -2783,7 +2801,11 @@ function buildTypedApprovalRequest(message) {
2783
2801
  .filter(Boolean)
2784
2802
  .filter((line) => !/^\[(?:Approve|Deny)\]/i.test(line));
2785
2803
  const rawTitle = (lines[0] || "ShieldCortex approval required").replace(/^🛡️\s*/u, "");
2786
- const details = lines.slice(1).join(" | ") || rawTitle;
2804
+ const detailLines = lines.slice(1);
2805
+ const withholdPayload = SECRET_EGRESS_PROMPT.test(message);
2806
+ const details = (withholdPayload
2807
+ ? [WITHHELD_COMMAND_TEXT, ...detailLines.filter((line) => SAFE_APPROVAL_LINE.test(line))]
2808
+ : detailLines).join(" | ") || rawTitle;
2787
2809
  const riskText = message.toLowerCase();
2788
2810
  const severity = /\b(?:critical|catastrophic|auto[-_\s]?deny|exfil|rm\s+-rf)\b/u.test(riskText)
2789
2811
  ? "critical"
@@ -2794,24 +2816,82 @@ function buildTypedApprovalRequest(message) {
2794
2816
  title: truncateApprovalText(rawTitle, 80),
2795
2817
  description: truncateApprovalText(details, 256),
2796
2818
  severity,
2797
- timeoutMs: 120_000,
2819
+ // The host's own ceiling (MAX_PLUGIN_APPROVAL_TIMEOUT_MS), matching
2820
+ // CARD_TIMEOUT_MS on the Telegram card path. 120s was the old bridge's
2821
+ // number and it expired cards the operator was still walking back to.
2822
+ timeoutMs: 600_000,
2798
2823
  timeoutBehavior: "deny",
2799
2824
  allowedDecisions: ["allow-once", "deny"],
2800
2825
  };
2801
2826
  }
2802
- async function handleTypedBeforeToolCall(event, interceptor, logger, sessionId) {
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
+ };
2848
+ async function handleTypedBeforeToolCall(event, interceptor, logger, ctx) {
2849
+ const sessionId = resolveHookSessionId(event, ctx);
2850
+ // #310: a card is only worth minting when a human is there to tap it. Cron
2851
+ // and heartbeat runs are host-keyed automation with no operator attached, so
2852
+ // offering OpenClaw an approval request there recreates #112 exactly — the
2853
+ // turn waits out the timeout on a decision nobody can give. Withholding
2854
+ // `requireApproval` puts the interceptor on its unattended branch instead,
2855
+ // which denies on the failure policy immediately and loudly.
2856
+ const attended = !isTrustedAutomationSession(sessionId);
2803
2857
  try {
2804
2858
  await interceptor.handleToolCall({
2805
2859
  toolName: event.toolName,
2806
2860
  arguments: event.params ?? {},
2807
2861
  sessionId,
2808
- requireApproval: async (message) => {
2809
- throw new TypedApprovalRequest(message, buildTypedApprovalRequest(message));
2810
- },
2862
+ ...(attended
2863
+ ? {
2864
+ requireApproval: async (message) => {
2865
+ throw new TypedApprovalRequest(message, buildTypedApprovalRequest(message));
2866
+ },
2867
+ }
2868
+ : {}),
2811
2869
  });
2812
2870
  }
2813
2871
  catch (err) {
2814
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
+ }
2815
2895
  return { requireApproval: err.request };
2816
2896
  }
2817
2897
  if (err instanceof Error && err.message.startsWith("ShieldCortex:")) {
@@ -3100,7 +3180,10 @@ export default {
3100
3180
  // #233: the host supplies the session on the tool CONTEXT, not the
3101
3181
  // event. Without it a taint cannot be matched to the call it should
3102
3182
  // gate, so the escalation would silently never fire.
3103
- return handleTypedBeforeToolCall(event, interceptor, api.logger, ctx?.sessionId);
3183
+ // #310: the WHOLE context, not just `sessionId` — resolveHookSessionId
3184
+ // also reads `sessionKey`, which is where a cron/heartbeat run's key
3185
+ // actually arrives, and that key decides whether a card is minted.
3186
+ return handleTypedBeforeToolCall(event, interceptor, api.logger, ctx);
3104
3187
  }, { priority: 80, timeoutMs: 30_000 });
3105
3188
  _beforeToolCallRegistered = true;
3106
3189
  // NOTE: session_end is NOT registered here — it moved out of this guard
@@ -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'],
@@ -274,6 +279,22 @@ export function formatActionGuardPrompt(toolName, v) {
274
279
  '[Approve] [Deny]',
275
280
  ].join('\n');
276
281
  }
282
+ /**
283
+ * #310: the OpenClaw-native approval card is delivered by THROWING out of the
284
+ * injected `requireApproval` — the plugin's typed-hook bridge catches that
285
+ * throw and hands `{ requireApproval }` back to the host, which draws the card.
286
+ * So this particular rejection is control flow, not a failure, and the catch
287
+ * blocks below must let it pass straight through.
288
+ *
289
+ * Matched by NAME, not by class: the class lives in the plugin entrypoint
290
+ * (index.ts) and this file is deliberately free of a compile-time dependency on
291
+ * it, the same discipline as ToolGuardVerdictLike. Swallowing it as an approval
292
+ * error is exactly what turned every native card into a `failure_denied` the
293
+ * operator never saw.
294
+ */
295
+ function isTypedApprovalRequest(err) {
296
+ return err instanceof Error && err.name === 'TypedApprovalRequest';
297
+ }
277
298
  // --- Audit Logging (local JSONL) ---
278
299
  /** Resolve per write so isolated tests can redirect every realtime audit path.
279
300
  * The conversation hook already honours this variable; the interceptor did not,
@@ -522,14 +543,16 @@ export function createInterceptor(config, pipeline, options) {
522
543
  const judgeLimiter = new RateLimiter(options?.maxJudgeCallsPerMinute ?? 20);
523
544
  /** Bare tool names seen this session, newest last. See buildSessionSummary. */
524
545
  const recentTools = [];
525
- function emitAudit(entry) {
526
- 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) {
527
550
  const withOrigin = {
528
551
  ...entry,
529
552
  origin: 'openclaw-interceptor',
530
- ...(sessionKey ? { sessionKey } : {}),
553
+ ...(captured.sessionKey ? { sessionKey: captured.sessionKey } : {}),
531
554
  };
532
- const bound = bindAudit ? bindAudit(withOrigin, lastCallArgs) : withOrigin;
555
+ const bound = bindAudit ? bindAudit(withOrigin, captured.args) : withOrigin;
533
556
  writeAuditEntry(bound);
534
557
  try {
535
558
  options?.sessionGuard?.index(bound);
@@ -537,6 +560,62 @@ export function createInterceptor(config, pipeline, options) {
537
560
  catch { /* never wedge the turn */ }
538
561
  onAuditEntry?.(bound);
539
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
+ }
540
619
  function guardAuditBase(toolName, v, preview) {
541
620
  return {
542
621
  type: 'intercept', tool: toolName,
@@ -912,6 +991,15 @@ export function createInterceptor(config, pipeline, options) {
912
991
  approved = await withApprovalDeadline(context.requireApproval(formatActionGuardPrompt(context.toolName, v)), brokered ? brokerApprovalTimeoutMs(v.severity) : 0);
913
992
  }
914
993
  catch (err) {
994
+ // #310: a minted approval card, not an error. Re-thrown untouched so the
995
+ // typed-hook bridge can turn it into the operator's card; auditing it
996
+ // here would write a denial for a decision nobody has made yet.
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);
1001
+ throw err;
1002
+ }
915
1003
  if (brokered && err instanceof ApprovalTimeout) {
916
1004
  // The asymmetric path. Silence is only ever a yes for something the
917
1005
  // broker already pre-cleared — and that returned long before here — so
@@ -1080,6 +1168,19 @@ export function createInterceptor(config, pipeline, options) {
1080
1168
  approved = await context.requireApproval(message);
1081
1169
  }
1082
1170
  catch (err) {
1171
+ // #310: same bridge, same rule — the card request is not an approval
1172
+ // failure. Everything else below stays fail-closed.
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
+ });
1182
+ throw err;
1183
+ }
1083
1184
  const failAction = config.failurePolicy[severity];
1084
1185
  log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
1085
1186
  const entry = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.54.7",
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,
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';
@@ -400,7 +400,15 @@ type AgentCtx = {
400
400
  type TypedBeforeToolCallEvent = {
401
401
  toolName: string;
402
402
  params?: Record<string, unknown>;
403
+ // Not supplied by today's OpenClaw (the session rides on the tool context —
404
+ // see #233), but resolveHookSessionId prefers the event when a host does
405
+ // send one, and every other hook's event carries it.
406
+ sessionId?: string;
403
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";
404
412
  type TypedBeforeToolCallResult = {
405
413
  block?: boolean;
406
414
  blockReason?: string;
@@ -410,8 +418,8 @@ type TypedBeforeToolCallResult = {
410
418
  severity?: "info" | "warning" | "critical";
411
419
  timeoutMs?: number;
412
420
  timeoutBehavior?: "allow" | "deny";
413
- allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
414
- 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;
415
423
  };
416
424
  };
417
425
  type PluginApi = {
@@ -3288,6 +3296,12 @@ function handleLlmOutput(event: LlmOutputEvent, ctx: AgentCtx): void {
3288
3296
 
3289
3297
  class TypedApprovalRequest extends Error {
3290
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;
3291
3305
 
3292
3306
  constructor(message: string, request: NonNullable<TypedBeforeToolCallResult["requireApproval"]>) {
3293
3307
  super(message);
@@ -3302,6 +3316,19 @@ function truncateApprovalText(text: string, maxLength: number): string {
3302
3316
  return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
3303
3317
  }
3304
3318
 
3319
+ /** A held command can itself contain the credential that tripped the guard, and
3320
+ * an approval card is a chat surface. Same rule, same phrasing and same
3321
+ * trade as the Telegram card path (isSecretEgress / buildCardFields in
3322
+ * src/defence/iron-dome/openclaw-approval-channel.ts): the operator keeps the
3323
+ * tool, the signals and the severity, and loses exactly the text that must not
3324
+ * be forwarded. */
3325
+ const SECRET_EGRESS_PROMPT = /secret|credential/iu;
3326
+ const WITHHELD_COMMAND_TEXT = "(command withheld — contains credential material)";
3327
+ /** Prompt lines safe to forward on a secret-egress hold — label-only metadata.
3328
+ * `Reason:` (action guard) and `Content:` (memory write) are the two lines
3329
+ * that quote the payload, so anything not on this list is dropped. */
3330
+ const SAFE_APPROVAL_LINE = /^(?:Tool|Action|Risk|Signals|Threats):/iu;
3331
+
3305
3332
  function buildTypedApprovalRequest(message: string): NonNullable<TypedBeforeToolCallResult["requireApproval"]> {
3306
3333
  const lines = message
3307
3334
  .split(/\r?\n/u)
@@ -3309,7 +3336,13 @@ function buildTypedApprovalRequest(message: string): NonNullable<TypedBeforeTool
3309
3336
  .filter(Boolean)
3310
3337
  .filter((line) => !/^\[(?:Approve|Deny)\]/i.test(line));
3311
3338
  const rawTitle = (lines[0] || "ShieldCortex approval required").replace(/^🛡️\s*/u, "");
3312
- const details = lines.slice(1).join(" | ") || rawTitle;
3339
+ const detailLines = lines.slice(1);
3340
+ const withholdPayload = SECRET_EGRESS_PROMPT.test(message);
3341
+ const details = (
3342
+ withholdPayload
3343
+ ? [WITHHELD_COMMAND_TEXT, ...detailLines.filter((line) => SAFE_APPROVAL_LINE.test(line))]
3344
+ : detailLines
3345
+ ).join(" | ") || rawTitle;
3313
3346
  const riskText = message.toLowerCase();
3314
3347
  const severity = /\b(?:critical|catastrophic|auto[-_\s]?deny|exfil|rm\s+-rf)\b/u.test(riskText)
3315
3348
  ? "critical"
@@ -3321,29 +3354,89 @@ function buildTypedApprovalRequest(message: string): NonNullable<TypedBeforeTool
3321
3354
  title: truncateApprovalText(rawTitle, 80),
3322
3355
  description: truncateApprovalText(details, 256),
3323
3356
  severity,
3324
- timeoutMs: 120_000,
3357
+ // The host's own ceiling (MAX_PLUGIN_APPROVAL_TIMEOUT_MS), matching
3358
+ // CARD_TIMEOUT_MS on the Telegram card path. 120s was the old bridge's
3359
+ // number and it expired cards the operator was still walking back to.
3360
+ timeoutMs: 600_000,
3325
3361
  timeoutBehavior: "deny",
3326
3362
  allowedDecisions: ["allow-once", "deny"],
3327
3363
  };
3328
3364
  }
3329
3365
 
3366
+ export const __buildTypedApprovalRequestForTest = buildTypedApprovalRequest;
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
+
3330
3390
  async function handleTypedBeforeToolCall(
3331
3391
  event: TypedBeforeToolCallEvent,
3332
3392
  interceptor: ReturnType<typeof createInterceptor>,
3333
3393
  logger: PluginApi["logger"],
3334
- sessionId?: string,
3394
+ ctx?: AgentCtx,
3335
3395
  ): Promise<TypedBeforeToolCallResult | void> {
3396
+ const sessionId = resolveHookSessionId(event, ctx);
3397
+ // #310: a card is only worth minting when a human is there to tap it. Cron
3398
+ // and heartbeat runs are host-keyed automation with no operator attached, so
3399
+ // offering OpenClaw an approval request there recreates #112 exactly — the
3400
+ // turn waits out the timeout on a decision nobody can give. Withholding
3401
+ // `requireApproval` puts the interceptor on its unattended branch instead,
3402
+ // which denies on the failure policy immediately and loudly.
3403
+ const attended = !isTrustedAutomationSession(sessionId);
3336
3404
  try {
3337
3405
  await interceptor.handleToolCall({
3338
3406
  toolName: event.toolName,
3339
3407
  arguments: event.params ?? {},
3340
3408
  sessionId,
3341
- requireApproval: async (message: string) => {
3342
- throw new TypedApprovalRequest(message, buildTypedApprovalRequest(message));
3343
- },
3409
+ ...(attended
3410
+ ? {
3411
+ requireApproval: async (message: string) => {
3412
+ throw new TypedApprovalRequest(message, buildTypedApprovalRequest(message));
3413
+ },
3414
+ }
3415
+ : {}),
3344
3416
  });
3345
3417
  } catch (err) {
3346
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
+ }
3347
3440
  return { requireApproval: err.request };
3348
3441
  }
3349
3442
 
@@ -3648,13 +3741,16 @@ export default {
3648
3741
  if (!interceptorDisabledInHostConfig) {
3649
3742
  // Typed before_tool_call hook: this is the OpenClaw agent-loop gate that
3650
3743
  // can block or require approval before the selected tool executes.
3651
- api.on('before_tool_call', async (event: TypedBeforeToolCallEvent, ctx?: { sessionId?: string }) => {
3744
+ api.on('before_tool_call', async (event: TypedBeforeToolCallEvent, ctx?: AgentCtx) => {
3652
3745
  const interceptor = await initInterceptor();
3653
3746
  if (!interceptor) return;
3654
3747
  // #233: the host supplies the session on the tool CONTEXT, not the
3655
3748
  // event. Without it a taint cannot be matched to the call it should
3656
3749
  // gate, so the escalation would silently never fire.
3657
- return handleTypedBeforeToolCall(event, interceptor, api.logger, ctx?.sessionId);
3750
+ // #310: the WHOLE context, not just `sessionId` — resolveHookSessionId
3751
+ // also reads `sessionKey`, which is where a cron/heartbeat run's key
3752
+ // actually arrives, and that key decides whether a card is minted.
3753
+ return handleTypedBeforeToolCall(event, interceptor, api.logger, ctx);
3658
3754
  }, { priority: 80, timeoutMs: 30_000 });
3659
3755
  _beforeToolCallRegistered = true;
3660
3756
  // NOTE: session_end is NOT registered here — it moved out of this guard
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
@@ -547,6 +596,23 @@ export function formatActionGuardPrompt(toolName: string, v: ToolGuardVerdictLik
547
596
  ].join('\n');
548
597
  }
549
598
 
599
+ /**
600
+ * #310: the OpenClaw-native approval card is delivered by THROWING out of the
601
+ * injected `requireApproval` — the plugin's typed-hook bridge catches that
602
+ * throw and hands `{ requireApproval }` back to the host, which draws the card.
603
+ * So this particular rejection is control flow, not a failure, and the catch
604
+ * blocks below must let it pass straight through.
605
+ *
606
+ * Matched by NAME, not by class: the class lives in the plugin entrypoint
607
+ * (index.ts) and this file is deliberately free of a compile-time dependency on
608
+ * it, the same discipline as ToolGuardVerdictLike. Swallowing it as an approval
609
+ * error is exactly what turned every native card into a `failure_denied` the
610
+ * operator never saw.
611
+ */
612
+ function isTypedApprovalRequest(err: unknown): err is Error & DecisionAuditCarrier {
613
+ return err instanceof Error && err.name === 'TypedApprovalRequest';
614
+ }
615
+
550
616
  // --- Audit Logging (local JSONL) ---
551
617
 
552
618
  /** Resolve per write so isolated tests can redirect every realtime audit path.
@@ -890,19 +956,79 @@ export function createInterceptor(
890
956
  /** Bare tool names seen this session, newest last. See buildSessionSummary. */
891
957
  const recentTools: string[] = [];
892
958
 
893
- function emitAudit(entry: InterceptAuditEntry): void {
894
- 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 {
895
963
  const withOrigin: InterceptAuditEntry = {
896
964
  ...entry,
897
965
  origin: 'openclaw-interceptor',
898
- ...(sessionKey ? { sessionKey } : {}),
966
+ ...(captured.sessionKey ? { sessionKey: captured.sessionKey } : {}),
899
967
  };
900
- const bound = bindAudit ? bindAudit(withOrigin, lastCallArgs) : withOrigin;
968
+ const bound = bindAudit ? bindAudit(withOrigin, captured.args) : withOrigin;
901
969
  writeAuditEntry(bound);
902
970
  try { options?.sessionGuard?.index(bound); } catch { /* never wedge the turn */ }
903
971
  onAuditEntry?.(bound);
904
972
  }
905
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
+
906
1032
  function guardAuditBase(toolName: string, v: ToolGuardVerdictLike, preview: string): Omit<InterceptAuditEntry, 'action' | 'outcome'> {
907
1033
  return {
908
1034
  type: 'intercept', tool: toolName,
@@ -1297,6 +1423,15 @@ export function createInterceptor(
1297
1423
  brokered ? brokerApprovalTimeoutMs(v.severity) : 0,
1298
1424
  );
1299
1425
  } catch (err) {
1426
+ // #310: a minted approval card, not an error. Re-thrown untouched so the
1427
+ // typed-hook bridge can turn it into the operator's card; auditing it
1428
+ // here would write a denial for a decision nobody has made yet.
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
+ }
1300
1435
  if (brokered && err instanceof ApprovalTimeout) {
1301
1436
  // The asymmetric path. Silence is only ever a yes for something the
1302
1437
  // broker already pre-cleared — and that returned long before here — so
@@ -1477,6 +1612,19 @@ export function createInterceptor(
1477
1612
  try {
1478
1613
  approved = await context.requireApproval(message);
1479
1614
  } catch (err) {
1615
+ // #310: same bridge, same rule — the card request is not an approval
1616
+ // failure. Everything else below stays fail-closed.
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
+ }
1480
1628
  const failAction = config.failurePolicy[severity];
1481
1629
  log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
1482
1630
  const entry: InterceptAuditEntry = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.54.7",
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.54.7",
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",
@@ -27,7 +27,7 @@
27
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
- "shieldcortex": "^4.54.6",
30
+ "shieldcortex": "^4.54.8",
31
31
  "openclaw": ">=2026.3.22"
32
32
  },
33
33
  "peerDependenciesMeta": {