@drakon-systems/shieldcortex-realtime 4.54.5 → 4.54.8
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 +42 -7
- package/dist/interceptor.js +25 -0
- package/dist/openclaw.plugin.json +7 -7
- package/index.ts +51 -8
- package/interceptor.ts +24 -0
- package/openclaw.plugin.json +7 -7
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -2776,6 +2776,18 @@ function truncateApprovalText(text, maxLength) {
|
|
|
2776
2776
|
return normalized;
|
|
2777
2777
|
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
2778
2778
|
}
|
|
2779
|
+
/** A held command can itself contain the credential that tripped the guard, and
|
|
2780
|
+
* an approval card is a chat surface. Same rule, same phrasing and same
|
|
2781
|
+
* trade as the Telegram card path (isSecretEgress / buildCardFields in
|
|
2782
|
+
* src/defence/iron-dome/openclaw-approval-channel.ts): the operator keeps the
|
|
2783
|
+
* tool, the signals and the severity, and loses exactly the text that must not
|
|
2784
|
+
* be forwarded. */
|
|
2785
|
+
const SECRET_EGRESS_PROMPT = /secret|credential/iu;
|
|
2786
|
+
const WITHHELD_COMMAND_TEXT = "(command withheld — contains credential material)";
|
|
2787
|
+
/** Prompt lines safe to forward on a secret-egress hold — label-only metadata.
|
|
2788
|
+
* `Reason:` (action guard) and `Content:` (memory write) are the two lines
|
|
2789
|
+
* that quote the payload, so anything not on this list is dropped. */
|
|
2790
|
+
const SAFE_APPROVAL_LINE = /^(?:Tool|Action|Risk|Signals|Threats):/iu;
|
|
2779
2791
|
function buildTypedApprovalRequest(message) {
|
|
2780
2792
|
const lines = message
|
|
2781
2793
|
.split(/\r?\n/u)
|
|
@@ -2783,7 +2795,11 @@ function buildTypedApprovalRequest(message) {
|
|
|
2783
2795
|
.filter(Boolean)
|
|
2784
2796
|
.filter((line) => !/^\[(?:Approve|Deny)\]/i.test(line));
|
|
2785
2797
|
const rawTitle = (lines[0] || "ShieldCortex approval required").replace(/^🛡️\s*/u, "");
|
|
2786
|
-
const
|
|
2798
|
+
const detailLines = lines.slice(1);
|
|
2799
|
+
const withholdPayload = SECRET_EGRESS_PROMPT.test(message);
|
|
2800
|
+
const details = (withholdPayload
|
|
2801
|
+
? [WITHHELD_COMMAND_TEXT, ...detailLines.filter((line) => SAFE_APPROVAL_LINE.test(line))]
|
|
2802
|
+
: detailLines).join(" | ") || rawTitle;
|
|
2787
2803
|
const riskText = message.toLowerCase();
|
|
2788
2804
|
const severity = /\b(?:critical|catastrophic|auto[-_\s]?deny|exfil|rm\s+-rf)\b/u.test(riskText)
|
|
2789
2805
|
? "critical"
|
|
@@ -2794,20 +2810,36 @@ function buildTypedApprovalRequest(message) {
|
|
|
2794
2810
|
title: truncateApprovalText(rawTitle, 80),
|
|
2795
2811
|
description: truncateApprovalText(details, 256),
|
|
2796
2812
|
severity,
|
|
2797
|
-
|
|
2813
|
+
// The host's own ceiling (MAX_PLUGIN_APPROVAL_TIMEOUT_MS), matching
|
|
2814
|
+
// CARD_TIMEOUT_MS on the Telegram card path. 120s was the old bridge's
|
|
2815
|
+
// number and it expired cards the operator was still walking back to.
|
|
2816
|
+
timeoutMs: 600_000,
|
|
2798
2817
|
timeoutBehavior: "deny",
|
|
2799
2818
|
allowedDecisions: ["allow-once", "deny"],
|
|
2800
2819
|
};
|
|
2801
2820
|
}
|
|
2802
|
-
|
|
2821
|
+
export const __buildTypedApprovalRequestForTest = buildTypedApprovalRequest;
|
|
2822
|
+
async function handleTypedBeforeToolCall(event, interceptor, logger, ctx) {
|
|
2823
|
+
const sessionId = resolveHookSessionId(event, ctx);
|
|
2824
|
+
// #310: a card is only worth minting when a human is there to tap it. Cron
|
|
2825
|
+
// and heartbeat runs are host-keyed automation with no operator attached, so
|
|
2826
|
+
// offering OpenClaw an approval request there recreates #112 exactly — the
|
|
2827
|
+
// turn waits out the timeout on a decision nobody can give. Withholding
|
|
2828
|
+
// `requireApproval` puts the interceptor on its unattended branch instead,
|
|
2829
|
+
// which denies on the failure policy immediately and loudly.
|
|
2830
|
+
const attended = !isTrustedAutomationSession(sessionId);
|
|
2803
2831
|
try {
|
|
2804
2832
|
await interceptor.handleToolCall({
|
|
2805
2833
|
toolName: event.toolName,
|
|
2806
2834
|
arguments: event.params ?? {},
|
|
2807
2835
|
sessionId,
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2836
|
+
...(attended
|
|
2837
|
+
? {
|
|
2838
|
+
requireApproval: async (message) => {
|
|
2839
|
+
throw new TypedApprovalRequest(message, buildTypedApprovalRequest(message));
|
|
2840
|
+
},
|
|
2841
|
+
}
|
|
2842
|
+
: {}),
|
|
2811
2843
|
});
|
|
2812
2844
|
}
|
|
2813
2845
|
catch (err) {
|
|
@@ -3100,7 +3132,10 @@ export default {
|
|
|
3100
3132
|
// #233: the host supplies the session on the tool CONTEXT, not the
|
|
3101
3133
|
// event. Without it a taint cannot be matched to the call it should
|
|
3102
3134
|
// gate, so the escalation would silently never fire.
|
|
3103
|
-
|
|
3135
|
+
// #310: the WHOLE context, not just `sessionId` — resolveHookSessionId
|
|
3136
|
+
// also reads `sessionKey`, which is where a cron/heartbeat run's key
|
|
3137
|
+
// actually arrives, and that key decides whether a card is minted.
|
|
3138
|
+
return handleTypedBeforeToolCall(event, interceptor, api.logger, ctx);
|
|
3104
3139
|
}, { priority: 80, timeoutMs: 30_000 });
|
|
3105
3140
|
_beforeToolCallRegistered = true;
|
|
3106
3141
|
// NOTE: session_end is NOT registered here — it moved out of this guard
|
package/dist/interceptor.js
CHANGED
|
@@ -274,6 +274,22 @@ export function formatActionGuardPrompt(toolName, v) {
|
|
|
274
274
|
'[Approve] [Deny]',
|
|
275
275
|
].join('\n');
|
|
276
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* #310: the OpenClaw-native approval card is delivered by THROWING out of the
|
|
279
|
+
* injected `requireApproval` — the plugin's typed-hook bridge catches that
|
|
280
|
+
* throw and hands `{ requireApproval }` back to the host, which draws the card.
|
|
281
|
+
* So this particular rejection is control flow, not a failure, and the catch
|
|
282
|
+
* blocks below must let it pass straight through.
|
|
283
|
+
*
|
|
284
|
+
* Matched by NAME, not by class: the class lives in the plugin entrypoint
|
|
285
|
+
* (index.ts) and this file is deliberately free of a compile-time dependency on
|
|
286
|
+
* it, the same discipline as ToolGuardVerdictLike. Swallowing it as an approval
|
|
287
|
+
* error is exactly what turned every native card into a `failure_denied` the
|
|
288
|
+
* operator never saw.
|
|
289
|
+
*/
|
|
290
|
+
function isTypedApprovalRequest(err) {
|
|
291
|
+
return err instanceof Error && err.name === 'TypedApprovalRequest';
|
|
292
|
+
}
|
|
277
293
|
// --- Audit Logging (local JSONL) ---
|
|
278
294
|
/** Resolve per write so isolated tests can redirect every realtime audit path.
|
|
279
295
|
* The conversation hook already honours this variable; the interceptor did not,
|
|
@@ -912,6 +928,11 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
912
928
|
approved = await withApprovalDeadline(context.requireApproval(formatActionGuardPrompt(context.toolName, v)), brokered ? brokerApprovalTimeoutMs(v.severity) : 0);
|
|
913
929
|
}
|
|
914
930
|
catch (err) {
|
|
931
|
+
// #310: a minted approval card, not an error. Re-thrown untouched so the
|
|
932
|
+
// typed-hook bridge can turn it into the operator's card; auditing it
|
|
933
|
+
// here would write a denial for a decision nobody has made yet.
|
|
934
|
+
if (isTypedApprovalRequest(err))
|
|
935
|
+
throw err;
|
|
915
936
|
if (brokered && err instanceof ApprovalTimeout) {
|
|
916
937
|
// The asymmetric path. Silence is only ever a yes for something the
|
|
917
938
|
// broker already pre-cleared — and that returned long before here — so
|
|
@@ -1080,6 +1101,10 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
1080
1101
|
approved = await context.requireApproval(message);
|
|
1081
1102
|
}
|
|
1082
1103
|
catch (err) {
|
|
1104
|
+
// #310: same bridge, same rule — the card request is not an approval
|
|
1105
|
+
// failure. Everything else below stays fail-closed.
|
|
1106
|
+
if (isTypedApprovalRequest(err))
|
|
1107
|
+
throw err;
|
|
1083
1108
|
const failAction = config.failurePolicy[severity];
|
|
1084
1109
|
log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
|
|
1085
1110
|
const entry = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.54.
|
|
3
|
+
"version": "4.54.8",
|
|
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
|
|
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.",
|
|
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
|
|
135
|
+
"description": "http(s) endpoint the notification is POSTed to. Conversation-firewall alerts carry no approve/deny affordance \u2014 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
|
|
159
|
+
"description": "Unused \u2014 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
|
|
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."
|
|
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
|
|
371
|
+
"description": "Operator-notify transport (#143), also used by the conversation firewall\u2019s 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
|
|
494
|
+
"description": "Operator-notify transport (#143), also used by the conversation firewall\u2019s detection sink (#225). Off unless enabled is exactly true.",
|
|
495
495
|
"properties": {
|
|
496
496
|
"enabled": {
|
|
497
497
|
"type": "boolean",
|
package/index.ts
CHANGED
|
@@ -400,6 +400,10 @@ 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
|
};
|
|
404
408
|
type TypedBeforeToolCallResult = {
|
|
405
409
|
block?: boolean;
|
|
@@ -3302,6 +3306,19 @@ function truncateApprovalText(text: string, maxLength: number): string {
|
|
|
3302
3306
|
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
3303
3307
|
}
|
|
3304
3308
|
|
|
3309
|
+
/** A held command can itself contain the credential that tripped the guard, and
|
|
3310
|
+
* an approval card is a chat surface. Same rule, same phrasing and same
|
|
3311
|
+
* trade as the Telegram card path (isSecretEgress / buildCardFields in
|
|
3312
|
+
* src/defence/iron-dome/openclaw-approval-channel.ts): the operator keeps the
|
|
3313
|
+
* tool, the signals and the severity, and loses exactly the text that must not
|
|
3314
|
+
* be forwarded. */
|
|
3315
|
+
const SECRET_EGRESS_PROMPT = /secret|credential/iu;
|
|
3316
|
+
const WITHHELD_COMMAND_TEXT = "(command withheld — contains credential material)";
|
|
3317
|
+
/** Prompt lines safe to forward on a secret-egress hold — label-only metadata.
|
|
3318
|
+
* `Reason:` (action guard) and `Content:` (memory write) are the two lines
|
|
3319
|
+
* that quote the payload, so anything not on this list is dropped. */
|
|
3320
|
+
const SAFE_APPROVAL_LINE = /^(?:Tool|Action|Risk|Signals|Threats):/iu;
|
|
3321
|
+
|
|
3305
3322
|
function buildTypedApprovalRequest(message: string): NonNullable<TypedBeforeToolCallResult["requireApproval"]> {
|
|
3306
3323
|
const lines = message
|
|
3307
3324
|
.split(/\r?\n/u)
|
|
@@ -3309,7 +3326,13 @@ function buildTypedApprovalRequest(message: string): NonNullable<TypedBeforeTool
|
|
|
3309
3326
|
.filter(Boolean)
|
|
3310
3327
|
.filter((line) => !/^\[(?:Approve|Deny)\]/i.test(line));
|
|
3311
3328
|
const rawTitle = (lines[0] || "ShieldCortex approval required").replace(/^🛡️\s*/u, "");
|
|
3312
|
-
const
|
|
3329
|
+
const detailLines = lines.slice(1);
|
|
3330
|
+
const withholdPayload = SECRET_EGRESS_PROMPT.test(message);
|
|
3331
|
+
const details = (
|
|
3332
|
+
withholdPayload
|
|
3333
|
+
? [WITHHELD_COMMAND_TEXT, ...detailLines.filter((line) => SAFE_APPROVAL_LINE.test(line))]
|
|
3334
|
+
: detailLines
|
|
3335
|
+
).join(" | ") || rawTitle;
|
|
3313
3336
|
const riskText = message.toLowerCase();
|
|
3314
3337
|
const severity = /\b(?:critical|catastrophic|auto[-_\s]?deny|exfil|rm\s+-rf)\b/u.test(riskText)
|
|
3315
3338
|
? "critical"
|
|
@@ -3321,26 +3344,43 @@ function buildTypedApprovalRequest(message: string): NonNullable<TypedBeforeTool
|
|
|
3321
3344
|
title: truncateApprovalText(rawTitle, 80),
|
|
3322
3345
|
description: truncateApprovalText(details, 256),
|
|
3323
3346
|
severity,
|
|
3324
|
-
|
|
3347
|
+
// The host's own ceiling (MAX_PLUGIN_APPROVAL_TIMEOUT_MS), matching
|
|
3348
|
+
// CARD_TIMEOUT_MS on the Telegram card path. 120s was the old bridge's
|
|
3349
|
+
// number and it expired cards the operator was still walking back to.
|
|
3350
|
+
timeoutMs: 600_000,
|
|
3325
3351
|
timeoutBehavior: "deny",
|
|
3326
3352
|
allowedDecisions: ["allow-once", "deny"],
|
|
3327
3353
|
};
|
|
3328
3354
|
}
|
|
3329
3355
|
|
|
3356
|
+
export const __buildTypedApprovalRequestForTest = buildTypedApprovalRequest;
|
|
3357
|
+
|
|
3330
3358
|
async function handleTypedBeforeToolCall(
|
|
3331
3359
|
event: TypedBeforeToolCallEvent,
|
|
3332
3360
|
interceptor: ReturnType<typeof createInterceptor>,
|
|
3333
3361
|
logger: PluginApi["logger"],
|
|
3334
|
-
|
|
3362
|
+
ctx?: AgentCtx,
|
|
3335
3363
|
): Promise<TypedBeforeToolCallResult | void> {
|
|
3364
|
+
const sessionId = resolveHookSessionId(event, ctx);
|
|
3365
|
+
// #310: a card is only worth minting when a human is there to tap it. Cron
|
|
3366
|
+
// and heartbeat runs are host-keyed automation with no operator attached, so
|
|
3367
|
+
// offering OpenClaw an approval request there recreates #112 exactly — the
|
|
3368
|
+
// turn waits out the timeout on a decision nobody can give. Withholding
|
|
3369
|
+
// `requireApproval` puts the interceptor on its unattended branch instead,
|
|
3370
|
+
// which denies on the failure policy immediately and loudly.
|
|
3371
|
+
const attended = !isTrustedAutomationSession(sessionId);
|
|
3336
3372
|
try {
|
|
3337
3373
|
await interceptor.handleToolCall({
|
|
3338
3374
|
toolName: event.toolName,
|
|
3339
3375
|
arguments: event.params ?? {},
|
|
3340
3376
|
sessionId,
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3377
|
+
...(attended
|
|
3378
|
+
? {
|
|
3379
|
+
requireApproval: async (message: string) => {
|
|
3380
|
+
throw new TypedApprovalRequest(message, buildTypedApprovalRequest(message));
|
|
3381
|
+
},
|
|
3382
|
+
}
|
|
3383
|
+
: {}),
|
|
3344
3384
|
});
|
|
3345
3385
|
} catch (err) {
|
|
3346
3386
|
if (err instanceof TypedApprovalRequest) {
|
|
@@ -3648,13 +3688,16 @@ export default {
|
|
|
3648
3688
|
if (!interceptorDisabledInHostConfig) {
|
|
3649
3689
|
// Typed before_tool_call hook: this is the OpenClaw agent-loop gate that
|
|
3650
3690
|
// can block or require approval before the selected tool executes.
|
|
3651
|
-
api.on('before_tool_call', async (event: TypedBeforeToolCallEvent, ctx?:
|
|
3691
|
+
api.on('before_tool_call', async (event: TypedBeforeToolCallEvent, ctx?: AgentCtx) => {
|
|
3652
3692
|
const interceptor = await initInterceptor();
|
|
3653
3693
|
if (!interceptor) return;
|
|
3654
3694
|
// #233: the host supplies the session on the tool CONTEXT, not the
|
|
3655
3695
|
// event. Without it a taint cannot be matched to the call it should
|
|
3656
3696
|
// gate, so the escalation would silently never fire.
|
|
3657
|
-
|
|
3697
|
+
// #310: the WHOLE context, not just `sessionId` — resolveHookSessionId
|
|
3698
|
+
// also reads `sessionKey`, which is where a cron/heartbeat run's key
|
|
3699
|
+
// actually arrives, and that key decides whether a card is minted.
|
|
3700
|
+
return handleTypedBeforeToolCall(event, interceptor, api.logger, ctx);
|
|
3658
3701
|
}, { priority: 80, timeoutMs: 30_000 });
|
|
3659
3702
|
_beforeToolCallRegistered = true;
|
|
3660
3703
|
// NOTE: session_end is NOT registered here — it moved out of this guard
|
package/interceptor.ts
CHANGED
|
@@ -547,6 +547,23 @@ export function formatActionGuardPrompt(toolName: string, v: ToolGuardVerdictLik
|
|
|
547
547
|
].join('\n');
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
/**
|
|
551
|
+
* #310: the OpenClaw-native approval card is delivered by THROWING out of the
|
|
552
|
+
* injected `requireApproval` — the plugin's typed-hook bridge catches that
|
|
553
|
+
* throw and hands `{ requireApproval }` back to the host, which draws the card.
|
|
554
|
+
* So this particular rejection is control flow, not a failure, and the catch
|
|
555
|
+
* blocks below must let it pass straight through.
|
|
556
|
+
*
|
|
557
|
+
* Matched by NAME, not by class: the class lives in the plugin entrypoint
|
|
558
|
+
* (index.ts) and this file is deliberately free of a compile-time dependency on
|
|
559
|
+
* it, the same discipline as ToolGuardVerdictLike. Swallowing it as an approval
|
|
560
|
+
* error is exactly what turned every native card into a `failure_denied` the
|
|
561
|
+
* operator never saw.
|
|
562
|
+
*/
|
|
563
|
+
function isTypedApprovalRequest(err: unknown): boolean {
|
|
564
|
+
return err instanceof Error && err.name === 'TypedApprovalRequest';
|
|
565
|
+
}
|
|
566
|
+
|
|
550
567
|
// --- Audit Logging (local JSONL) ---
|
|
551
568
|
|
|
552
569
|
/** Resolve per write so isolated tests can redirect every realtime audit path.
|
|
@@ -1297,6 +1314,10 @@ export function createInterceptor(
|
|
|
1297
1314
|
brokered ? brokerApprovalTimeoutMs(v.severity) : 0,
|
|
1298
1315
|
);
|
|
1299
1316
|
} catch (err) {
|
|
1317
|
+
// #310: a minted approval card, not an error. Re-thrown untouched so the
|
|
1318
|
+
// typed-hook bridge can turn it into the operator's card; auditing it
|
|
1319
|
+
// here would write a denial for a decision nobody has made yet.
|
|
1320
|
+
if (isTypedApprovalRequest(err)) throw err;
|
|
1300
1321
|
if (brokered && err instanceof ApprovalTimeout) {
|
|
1301
1322
|
// The asymmetric path. Silence is only ever a yes for something the
|
|
1302
1323
|
// broker already pre-cleared — and that returned long before here — so
|
|
@@ -1477,6 +1498,9 @@ export function createInterceptor(
|
|
|
1477
1498
|
try {
|
|
1478
1499
|
approved = await context.requireApproval(message);
|
|
1479
1500
|
} catch (err) {
|
|
1501
|
+
// #310: same bridge, same rule — the card request is not an approval
|
|
1502
|
+
// failure. Everything else below stays fail-closed.
|
|
1503
|
+
if (isTypedApprovalRequest(err)) throw err;
|
|
1480
1504
|
const failAction = config.failurePolicy[severity];
|
|
1481
1505
|
log.warn(`[shieldcortex] ⚠️ requireApproval error: ${err instanceof Error ? err.message : err} — failure policy: ${failAction}`);
|
|
1482
1506
|
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.8",
|
|
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
|
|
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.",
|
|
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
|
|
135
|
+
"description": "http(s) endpoint the notification is POSTed to. Conversation-firewall alerts carry no approve/deny affordance \u2014 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
|
|
159
|
+
"description": "Unused \u2014 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
|
|
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."
|
|
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
|
|
371
|
+
"description": "Operator-notify transport (#143), also used by the conversation firewall\u2019s 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
|
|
494
|
+
"description": "Operator-notify transport (#143), also used by the conversation firewall\u2019s 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.
|
|
3
|
+
"version": "4.54.8",
|
|
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,10 +24,10 @@
|
|
|
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
|
|
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')\""
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"shieldcortex": "
|
|
30
|
+
"shieldcortex": "^4.54.8",
|
|
31
31
|
"openclaw": ">=2026.3.22"
|
|
32
32
|
},
|
|
33
33
|
"peerDependenciesMeta": {
|