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