@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 +90 -7
- package/dist/interceptor.js +105 -4
- package/dist/openclaw.plugin.json +1 -1
- package/index.ts +107 -11
- package/interceptor.ts +153 -5
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2809
|
-
|
|
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
|
-
|
|
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
|
package/dist/interceptor.js
CHANGED
|
@@ -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
|
-
|
|
526
|
-
|
|
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,
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3342
|
-
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
894
|
-
|
|
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,
|
|
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 = {
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.54.
|
|
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.
|
|
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.
|
|
30
|
+
"shieldcortex": "^4.54.8",
|
|
31
31
|
"openclaw": ">=2026.3.22"
|
|
32
32
|
},
|
|
33
33
|
"peerDependenciesMeta": {
|