@drakon-systems/shieldcortex-realtime 4.50.0 → 4.52.0
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/README.md +2 -1
- package/dist/index.js +78 -3
- package/dist/interceptor.js +38 -4
- package/dist/openclaw.plugin.json +3 -2
- package/index.ts +96 -3
- package/interceptor.ts +86 -14
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,7 +32,8 @@ The defensive root `openclaw.plugin.json` is kept for one release on the main pa
|
|
|
32
32
|
| `before_agent_run` | **The conversation firewall's enforcement point.** The documented input gate — it is awaited and its result decides whether the run proceeds. Behaviour is set by `interceptor.conversation.posture` (see [Conversation firewall](#conversation-firewall)). |
|
|
33
33
|
| `llm_output` | Extracts high-signal memories from assistant replies and writes them into ShieldCortex with novelty filtering and dedupe. |
|
|
34
34
|
| `before_tool_call` | Runs the Action Guard before tools execute. Catastrophic shell/file/network/git actions are always blocked. Recognised-dangerous actions are **enforced by default**: attended sessions get an approval prompt, unattended sessions fail closed per `failurePolicy`. Set `actionGuard.enforce: false` to opt down to warn-and-allow, or pre-approve specific operations with `actionGuard.autoApprove`. |
|
|
35
|
-
| `session_end` | Resets the interceptor's per-session caches
|
|
35
|
+
| `session_end` | Resets the interceptor's per-session caches, releases that session's scan-unavailable alert window, and (with `agent_end`) writes `action_guard_degraded` when the Action Guard denied or warned during the session. Registered even when `interceptor.enabled` is `false`, because the conversation gate keeps per-session state regardless. Neither hook can block, approve, or delay a turn. |
|
|
36
|
+
| `agent_end` | Same degraded-run summariser as `session_end`, idempotent with it. Present on OpenClaw 2026.5.7+; an older host warns-and-returns and `session_end` still summarises. |
|
|
36
37
|
| `/shieldcortex-status` | Slash command reporting the plugin's runtime state. |
|
|
37
38
|
|
|
38
39
|
The scanning and memory paths are fire-and-forget: they do not stall the OpenClaw turn loop if ShieldCortex is unavailable. The Action Guard is the deliberate exception — it gates tool calls inline, and since 4.47.5 a guard that fails to load falls back to a dependency-free scanner that still denies unambiguous catastrophic operations (fail-closed) rather than allowing everything.
|
package/dist/index.js
CHANGED
|
@@ -239,6 +239,24 @@ export function __setDefenceModuleForTest(mod) {
|
|
|
239
239
|
_defenceModOverride = mod;
|
|
240
240
|
_defenceModPromise = null;
|
|
241
241
|
}
|
|
242
|
+
/** #260 — emit action_guard_degraded for this session. Never throws, never
|
|
243
|
+
* waits when the defence module is already injected (the test / in-process
|
|
244
|
+
* path). A missing module is a silent no-op: session_end cannot block (#112). */
|
|
245
|
+
function summariseGuardSession(sessionId, origin) {
|
|
246
|
+
if (!sessionId)
|
|
247
|
+
return;
|
|
248
|
+
const run = (mod) => {
|
|
249
|
+
try {
|
|
250
|
+
mod?.recordActionGuardDegraded?.(sessionId, { origin });
|
|
251
|
+
}
|
|
252
|
+
catch { /* never wedge */ }
|
|
253
|
+
};
|
|
254
|
+
if (_defenceModOverride !== undefined) {
|
|
255
|
+
run(_defenceModOverride);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
void getDefenceModule().then(run).catch(() => { });
|
|
259
|
+
}
|
|
242
260
|
export function __setRuntimeForTest(runtime) {
|
|
243
261
|
_runtimeOverride = runtime;
|
|
244
262
|
if (runtime)
|
|
@@ -1690,8 +1708,23 @@ const MIN_NOVELTY_CHARS = 40;
|
|
|
1690
1708
|
async function auditLog(entry) {
|
|
1691
1709
|
const dir = auditDir();
|
|
1692
1710
|
try {
|
|
1711
|
+
const hookName = typeof entry.hook === 'string' && entry.hook ? entry.hook : 'llm_input';
|
|
1712
|
+
const plane = hookName === 'before_tool_call' ? 'action_guard' : 'conversation_firewall';
|
|
1713
|
+
let bound = entry;
|
|
1714
|
+
try {
|
|
1715
|
+
const defenceMod = await getDefenceModule();
|
|
1716
|
+
if (typeof defenceMod?.attachEnforcementBinding === 'function') {
|
|
1717
|
+
bound = defenceMod.attachEnforcementBinding(entry, {
|
|
1718
|
+
plane,
|
|
1719
|
+
hookName,
|
|
1720
|
+
pluginId: 'shieldcortex-realtime',
|
|
1721
|
+
actionKey: typeof entry.actionKey === 'string' ? entry.actionKey : `conversation:${hookName}`,
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
catch { /* older package / bind failure — write the unbound row */ }
|
|
1693
1726
|
await fs.mkdir(dir, { recursive: true });
|
|
1694
|
-
await fs.appendFile(path.join(dir, `realtime-${new Date().toISOString().slice(0, 10)}.jsonl`), JSON.stringify(
|
|
1727
|
+
await fs.appendFile(path.join(dir, `realtime-${new Date().toISOString().slice(0, 10)}.jsonl`), JSON.stringify(bound) + "\n");
|
|
1695
1728
|
return true;
|
|
1696
1729
|
}
|
|
1697
1730
|
catch (err) {
|
|
@@ -2728,6 +2761,10 @@ function resolveBrokerRuntime(defenceMod, rawBrokerConfig, api) {
|
|
|
2728
2761
|
return {
|
|
2729
2762
|
config,
|
|
2730
2763
|
runJudge: defenceMod.runJudge,
|
|
2764
|
+
// #143 residual, and deliberately NOT in `needed`: an older shieldcortex
|
|
2765
|
+
// build has runJudge alone, and the interceptor falls back to it. Missing
|
|
2766
|
+
// it costs an audit field, never a gate.
|
|
2767
|
+
runJudgeDetailed: typeof defenceMod.runJudgeDetailed === 'function' ? defenceMod.runJudgeDetailed : undefined,
|
|
2731
2768
|
brokerDecision: defenceMod.brokerDecision,
|
|
2732
2769
|
timeoutOutcome: defenceMod.timeoutOutcome,
|
|
2733
2770
|
approvalTimeoutMs: typeof defenceMod.approvalTimeoutMs === 'function' ? defenceMod.approvalTimeoutMs : undefined,
|
|
@@ -2910,11 +2947,31 @@ export default {
|
|
|
2910
2947
|
releaseActionLease: typeof defenceMod.releaseToolCallLease === 'function'
|
|
2911
2948
|
? (toolName, args, sessionId) => defenceMod.releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
|
|
2912
2949
|
: undefined,
|
|
2950
|
+
// #260: the session-guard index. Same formula as the Claude Code
|
|
2951
|
+
// hook. Absent on an older dist — then emitAudit still stamps origin
|
|
2952
|
+
// but does not write an index nobody would summarise.
|
|
2953
|
+
sessionGuard: typeof defenceMod.sessionKeyFor === 'function' && typeof defenceMod.appendSessionGuardIndex === 'function'
|
|
2954
|
+
? {
|
|
2955
|
+
keyFor: (sessionId) => defenceMod.sessionKeyFor(sessionId),
|
|
2956
|
+
index: (entry) => {
|
|
2957
|
+
defenceMod.appendSessionGuardIndex({ entry: { ...entry } });
|
|
2958
|
+
},
|
|
2959
|
+
}
|
|
2960
|
+
: undefined,
|
|
2913
2961
|
onAuditEntry: (entry) => syncInterceptEvent(entry, {
|
|
2914
2962
|
cloudApiKey: scConfig.cloudApiKey ?? '',
|
|
2915
2963
|
cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
|
|
2916
2964
|
cloudEnabled: scConfig.cloudEnabled ?? false,
|
|
2917
2965
|
}),
|
|
2966
|
+
bindAudit: typeof defenceMod.attachEnforcementBinding === 'function'
|
|
2967
|
+
? (entry, args) => defenceMod.attachEnforcementBinding(entry, {
|
|
2968
|
+
plane: 'action_guard',
|
|
2969
|
+
hookName: 'before_tool_call',
|
|
2970
|
+
pluginId: 'shieldcortex-realtime',
|
|
2971
|
+
tool: entry.tool,
|
|
2972
|
+
args: args ?? {},
|
|
2973
|
+
})
|
|
2974
|
+
: undefined,
|
|
2918
2975
|
});
|
|
2919
2976
|
const guardState = interceptorConfig.actionGuard?.enabled
|
|
2920
2977
|
? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
|
|
@@ -2976,8 +3033,9 @@ export default {
|
|
|
2976
3033
|
// `before_tool_call`: a registered approval hook changes how OpenClaw
|
|
2977
3034
|
// resolves tool-call approvals for unattended Codex agents, so an
|
|
2978
3035
|
// unattended turn waited 120s on a decision nobody could give. `session_end`
|
|
2979
|
-
// is a notification — it cannot block, approve, or delay anything
|
|
2980
|
-
//
|
|
3036
|
+
// is a notification — it cannot block, approve, or delay anything. It frees
|
|
3037
|
+
// local state and, since #260, best-effort summarises a degraded Action
|
|
3038
|
+
// Guard session. That write is not a decision and cannot stall the host.
|
|
2981
3039
|
try {
|
|
2982
3040
|
api.on('session_end', (event, ctx) => {
|
|
2983
3041
|
interceptorReady?.resetSession();
|
|
@@ -2991,11 +3049,28 @@ export default {
|
|
|
2991
3049
|
// per-session rule, for the same reason.
|
|
2992
3050
|
if (endedSession)
|
|
2993
3051
|
sessionTaint.clear(endedSession);
|
|
3052
|
+
// #260: plane-native summariser. session_end cannot block (#112) —
|
|
3053
|
+
// this is a notification hook. The write is best-effort and
|
|
3054
|
+
// idempotent with agent_end below.
|
|
3055
|
+
summariseGuardSession(endedSession, 'openclaw-session-end');
|
|
2994
3056
|
});
|
|
2995
3057
|
}
|
|
2996
3058
|
catch {
|
|
2997
3059
|
// session_end may not be a supported hook — TTL safety net handles this
|
|
2998
3060
|
}
|
|
3061
|
+
// #260: agent_end exists on the engine floor (2026.5.7 already declared
|
|
3062
|
+
// it). An unknown typed hook is warn-and-return, not a throw, so we still
|
|
3063
|
+
// wrap registration. Same summariser as session_end — whichever fires
|
|
3064
|
+
// first writes, the other is a no-op. Do not invent a third sink.
|
|
3065
|
+
try {
|
|
3066
|
+
api.on('agent_end', (event, ctx) => {
|
|
3067
|
+
const endedSession = ctx?.sessionId ?? ctx?.sessionKey ?? event?.sessionId ?? event?.sessionKey ?? null;
|
|
3068
|
+
summariseGuardSession(endedSession, 'openclaw-session-end');
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
catch {
|
|
3072
|
+
// Host predates agent_end — session_end is the load-bearing summariser.
|
|
3073
|
+
}
|
|
2999
3074
|
// llm_input/llm_output are CONVERSATION hooks: OpenClaw drops them at
|
|
3000
3075
|
// registration for a non-bundled plugin unless the host grants
|
|
3001
3076
|
// plugins.entries.<id>.hooks.allowConversationAccess = true. Registration is
|
package/dist/interceptor.js
CHANGED
|
@@ -509,6 +509,10 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
509
509
|
const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
|
|
510
510
|
const log = config.logger ?? { info: console.log, warn: console.warn };
|
|
511
511
|
const onAuditEntry = options?.onAuditEntry;
|
|
512
|
+
let lastSessionId;
|
|
513
|
+
const bindAudit = options?.bindAudit;
|
|
514
|
+
/** Args of the in-flight tool call — used only to mint #224 actionKey. */
|
|
515
|
+
let lastCallArgs;
|
|
512
516
|
const actionGuardCfg = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
|
|
513
517
|
const evaluateToolCall = options?.evaluateToolCall;
|
|
514
518
|
const broker = options?.broker;
|
|
@@ -519,8 +523,19 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
519
523
|
/** Bare tool names seen this session, newest last. See buildSessionSummary. */
|
|
520
524
|
const recentTools = [];
|
|
521
525
|
function emitAudit(entry) {
|
|
522
|
-
|
|
523
|
-
|
|
526
|
+
const sessionKey = options?.sessionGuard?.keyFor(lastSessionId) ?? undefined;
|
|
527
|
+
const withOrigin = {
|
|
528
|
+
...entry,
|
|
529
|
+
origin: 'openclaw-interceptor',
|
|
530
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
531
|
+
};
|
|
532
|
+
const bound = bindAudit ? bindAudit(withOrigin, lastCallArgs) : withOrigin;
|
|
533
|
+
writeAuditEntry(bound);
|
|
534
|
+
try {
|
|
535
|
+
options?.sessionGuard?.index(bound);
|
|
536
|
+
}
|
|
537
|
+
catch { /* never wedge the turn */ }
|
|
538
|
+
onAuditEntry?.(bound);
|
|
524
539
|
}
|
|
525
540
|
function guardAuditBase(toolName, v, preview) {
|
|
526
541
|
return {
|
|
@@ -587,13 +602,29 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
587
602
|
timeoutMs: broker.config.judgeTimeoutMs,
|
|
588
603
|
});
|
|
589
604
|
let judge = null;
|
|
605
|
+
// Only set when a judge pass actually ran: "no seam on this build" and
|
|
606
|
+
// "budget spent" are not timeouts, and claiming otherwise would be the
|
|
607
|
+
// same overclaim in the audit that this residual removes from doctor.
|
|
608
|
+
let judgeMeta;
|
|
590
609
|
if (invoke && judgeLimiter.shouldAllow()) {
|
|
591
|
-
|
|
610
|
+
const request = {
|
|
592
611
|
tool: context.toolName,
|
|
593
612
|
toolInput: context.arguments,
|
|
594
613
|
verdict: { severity: v.severity, action: v.action, reason: v.reason, signals: v.signals },
|
|
595
614
|
sessionSummary: buildSessionSummary(),
|
|
596
|
-
}
|
|
615
|
+
};
|
|
616
|
+
const opts = { timeoutMs: broker.config.judgeTimeoutMs };
|
|
617
|
+
if (typeof broker.runJudgeDetailed === 'function') {
|
|
618
|
+
const detailed = await broker.runJudgeDetailed(request, invoke, opts);
|
|
619
|
+
judge = detailed?.result ?? null;
|
|
620
|
+
judgeMeta = {
|
|
621
|
+
timedOut: detailed?.timedOut === true,
|
|
622
|
+
error: typeof detailed?.error === 'string' ? detailed.error : null,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
judge = await broker.runJudge(request, invoke, opts);
|
|
627
|
+
}
|
|
597
628
|
}
|
|
598
629
|
else if (invoke) {
|
|
599
630
|
log.warn(`[shieldcortex] approval broker: judge budget spent this minute — holding ${context.toolName} for the operator`);
|
|
@@ -603,6 +634,7 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
603
634
|
toolInput: context.arguments,
|
|
604
635
|
verdict: v,
|
|
605
636
|
judge,
|
|
637
|
+
...(judgeMeta ? { judgeMeta } : {}),
|
|
606
638
|
policy: {
|
|
607
639
|
allowPreClear: broker.config.allowPreClear,
|
|
608
640
|
preClearConfidence: broker.config.preClearConfidence,
|
|
@@ -910,6 +942,8 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
910
942
|
throw new Error('ShieldCortex: tool call denied by user');
|
|
911
943
|
}
|
|
912
944
|
async function handleToolCall(context) {
|
|
945
|
+
lastSessionId = context.sessionId;
|
|
946
|
+
lastCallArgs = context.arguments;
|
|
913
947
|
// Remember the NAME only. This is the entirety of what the approval broker's
|
|
914
948
|
// judge will ever learn about the session — see buildSessionSummary.
|
|
915
949
|
noteToolForSession(context.toolName);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.52.0",
|
|
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,
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"llm_output",
|
|
19
19
|
"before_agent_run",
|
|
20
20
|
"before_tool_call",
|
|
21
|
-
"session_end"
|
|
21
|
+
"session_end",
|
|
22
|
+
"agent_end"
|
|
22
23
|
],
|
|
23
24
|
"commands": [
|
|
24
25
|
"shieldcortex-status"
|
package/index.ts
CHANGED
|
@@ -100,6 +100,27 @@ type DefenceModule = {
|
|
|
100
100
|
notification: unknown,
|
|
101
101
|
deps: { channels: NotifyChannelLike[]; timeoutMs?: number },
|
|
102
102
|
) => Promise<{ deliveredVia: string | null; attempts: Array<{ channel: string; result: { delivered: boolean; reason?: string } }> }>;
|
|
103
|
+
/** #260 — session-guard index + degraded-run summary. Optional so an older
|
|
104
|
+
* installed dist degrades to "no index" rather than crashing the hook. */
|
|
105
|
+
sessionKeyFor?: (value: string | undefined, opts?: { home?: string; salt?: string }) => string | null;
|
|
106
|
+
appendSessionGuardIndex?: (opts: { home?: string; entry: Record<string, unknown> }) => boolean;
|
|
107
|
+
recordActionGuardDegraded?: (
|
|
108
|
+
rawSessionId: string | undefined,
|
|
109
|
+
opts?: { home?: string; salt?: string; origin?: string },
|
|
110
|
+
) => { recorded: boolean; count: number; sessionKey?: string; existing?: boolean };
|
|
111
|
+
/** #224 — stamp binding fields on a realtime audit row. Optional so an
|
|
112
|
+
* older installed package degrades to unbound records, not a crash. */
|
|
113
|
+
attachEnforcementBinding?: (
|
|
114
|
+
entry: Record<string, unknown>,
|
|
115
|
+
ctx: {
|
|
116
|
+
plane: 'action_guard' | 'conversation_firewall';
|
|
117
|
+
hookName: string;
|
|
118
|
+
pluginId: string;
|
|
119
|
+
tool?: string;
|
|
120
|
+
args?: Record<string, unknown>;
|
|
121
|
+
actionKey?: string;
|
|
122
|
+
},
|
|
123
|
+
) => Record<string, unknown>;
|
|
103
124
|
};
|
|
104
125
|
|
|
105
126
|
let runtimePromise: Promise<OpenClawRuntime> | null = null;
|
|
@@ -322,6 +343,21 @@ export function __setDefenceModuleForTest(mod: DefenceModule | null | undefined)
|
|
|
322
343
|
_defenceModOverride = mod;
|
|
323
344
|
_defenceModPromise = null;
|
|
324
345
|
}
|
|
346
|
+
|
|
347
|
+
/** #260 — emit action_guard_degraded for this session. Never throws, never
|
|
348
|
+
* waits when the defence module is already injected (the test / in-process
|
|
349
|
+
* path). A missing module is a silent no-op: session_end cannot block (#112). */
|
|
350
|
+
function summariseGuardSession(sessionId: string | null, origin: string): void {
|
|
351
|
+
if (!sessionId) return;
|
|
352
|
+
const run = (mod: DefenceModule | null) => {
|
|
353
|
+
try { mod?.recordActionGuardDegraded?.(sessionId, { origin }); } catch { /* never wedge */ }
|
|
354
|
+
};
|
|
355
|
+
if (_defenceModOverride !== undefined) {
|
|
356
|
+
run(_defenceModOverride);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
void getDefenceModule().then(run).catch(() => {});
|
|
360
|
+
}
|
|
325
361
|
export function __setRuntimeForTest(runtime: OpenClawRuntime | null): void {
|
|
326
362
|
_runtimeOverride = runtime;
|
|
327
363
|
if (runtime) runtimePromise = null;
|
|
@@ -2036,10 +2072,24 @@ const MIN_NOVELTY_CHARS = 40;
|
|
|
2036
2072
|
async function auditLog(entry: Record<string, unknown>): Promise<boolean> {
|
|
2037
2073
|
const dir = auditDir();
|
|
2038
2074
|
try {
|
|
2075
|
+
const hookName = typeof entry.hook === 'string' && entry.hook ? entry.hook : 'llm_input';
|
|
2076
|
+
const plane = hookName === 'before_tool_call' ? 'action_guard' : 'conversation_firewall';
|
|
2077
|
+
let bound = entry;
|
|
2078
|
+
try {
|
|
2079
|
+
const defenceMod = await getDefenceModule();
|
|
2080
|
+
if (typeof defenceMod?.attachEnforcementBinding === 'function') {
|
|
2081
|
+
bound = defenceMod.attachEnforcementBinding(entry, {
|
|
2082
|
+
plane,
|
|
2083
|
+
hookName,
|
|
2084
|
+
pluginId: 'shieldcortex-realtime',
|
|
2085
|
+
actionKey: typeof entry.actionKey === 'string' ? entry.actionKey : `conversation:${hookName}`,
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
} catch { /* older package / bind failure — write the unbound row */ }
|
|
2039
2089
|
await fs.mkdir(dir, { recursive: true });
|
|
2040
2090
|
await fs.appendFile(
|
|
2041
2091
|
path.join(dir, `realtime-${new Date().toISOString().slice(0, 10)}.jsonl`),
|
|
2042
|
-
JSON.stringify(
|
|
2092
|
+
JSON.stringify(bound) + "\n",
|
|
2043
2093
|
);
|
|
2044
2094
|
return true;
|
|
2045
2095
|
} catch (err) {
|
|
@@ -3250,6 +3300,11 @@ function resolveBrokerRuntime(
|
|
|
3250
3300
|
return {
|
|
3251
3301
|
config,
|
|
3252
3302
|
runJudge: defenceMod.runJudge,
|
|
3303
|
+
// #143 residual, and deliberately NOT in `needed`: an older shieldcortex
|
|
3304
|
+
// build has runJudge alone, and the interceptor falls back to it. Missing
|
|
3305
|
+
// it costs an audit field, never a gate.
|
|
3306
|
+
runJudgeDetailed:
|
|
3307
|
+
typeof defenceMod.runJudgeDetailed === 'function' ? defenceMod.runJudgeDetailed : undefined,
|
|
3253
3308
|
brokerDecision: defenceMod.brokerDecision,
|
|
3254
3309
|
timeoutOutcome: defenceMod.timeoutOutcome,
|
|
3255
3310
|
approvalTimeoutMs: typeof defenceMod.approvalTimeoutMs === 'function' ? defenceMod.approvalTimeoutMs : undefined,
|
|
@@ -3443,11 +3498,31 @@ export default {
|
|
|
3443
3498
|
? (toolName, args, sessionId) =>
|
|
3444
3499
|
(defenceMod as any).releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
|
|
3445
3500
|
: undefined,
|
|
3501
|
+
// #260: the session-guard index. Same formula as the Claude Code
|
|
3502
|
+
// hook. Absent on an older dist — then emitAudit still stamps origin
|
|
3503
|
+
// but does not write an index nobody would summarise.
|
|
3504
|
+
sessionGuard: typeof defenceMod.sessionKeyFor === 'function' && typeof defenceMod.appendSessionGuardIndex === 'function'
|
|
3505
|
+
? {
|
|
3506
|
+
keyFor: (sessionId) => defenceMod.sessionKeyFor!(sessionId),
|
|
3507
|
+
index: (entry) => {
|
|
3508
|
+
defenceMod.appendSessionGuardIndex!({ entry: { ...entry } as Record<string, unknown> });
|
|
3509
|
+
},
|
|
3510
|
+
}
|
|
3511
|
+
: undefined,
|
|
3446
3512
|
onAuditEntry: (entry) => syncInterceptEvent(entry, {
|
|
3447
3513
|
cloudApiKey: (scConfig as any).cloudApiKey ?? '',
|
|
3448
3514
|
cloudBaseUrl: (scConfig as any).cloudBaseUrl ?? 'https://api.shieldcortex.ai',
|
|
3449
3515
|
cloudEnabled: (scConfig as any).cloudEnabled ?? false,
|
|
3450
3516
|
}),
|
|
3517
|
+
bindAudit: typeof (defenceMod as any).attachEnforcementBinding === 'function'
|
|
3518
|
+
? (entry, args) => (defenceMod as any).attachEnforcementBinding(entry, {
|
|
3519
|
+
plane: 'action_guard',
|
|
3520
|
+
hookName: 'before_tool_call',
|
|
3521
|
+
pluginId: 'shieldcortex-realtime',
|
|
3522
|
+
tool: entry.tool,
|
|
3523
|
+
args: args ?? {},
|
|
3524
|
+
}) as typeof entry
|
|
3525
|
+
: undefined,
|
|
3451
3526
|
});
|
|
3452
3527
|
const guardState = interceptorConfig.actionGuard?.enabled
|
|
3453
3528
|
? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
|
|
@@ -3509,8 +3584,9 @@ export default {
|
|
|
3509
3584
|
// `before_tool_call`: a registered approval hook changes how OpenClaw
|
|
3510
3585
|
// resolves tool-call approvals for unattended Codex agents, so an
|
|
3511
3586
|
// unattended turn waited 120s on a decision nobody could give. `session_end`
|
|
3512
|
-
// is a notification — it cannot block, approve, or delay anything
|
|
3513
|
-
//
|
|
3587
|
+
// is a notification — it cannot block, approve, or delay anything. It frees
|
|
3588
|
+
// local state and, since #260, best-effort summarises a degraded Action
|
|
3589
|
+
// Guard session. That write is not a decision and cannot stall the host.
|
|
3514
3590
|
try {
|
|
3515
3591
|
api.on('session_end', (event?: { sessionId?: string; sessionKey?: string }, ctx?: AgentCtx) => {
|
|
3516
3592
|
interceptorReady?.resetSession();
|
|
@@ -3523,11 +3599,28 @@ export default {
|
|
|
3523
3599
|
// #233: a taint must not outlive the conversation that earned it. Same
|
|
3524
3600
|
// per-session rule, for the same reason.
|
|
3525
3601
|
if (endedSession) sessionTaint.clear(endedSession);
|
|
3602
|
+
// #260: plane-native summariser. session_end cannot block (#112) —
|
|
3603
|
+
// this is a notification hook. The write is best-effort and
|
|
3604
|
+
// idempotent with agent_end below.
|
|
3605
|
+
summariseGuardSession(endedSession, 'openclaw-session-end');
|
|
3526
3606
|
});
|
|
3527
3607
|
} catch {
|
|
3528
3608
|
// session_end may not be a supported hook — TTL safety net handles this
|
|
3529
3609
|
}
|
|
3530
3610
|
|
|
3611
|
+
// #260: agent_end exists on the engine floor (2026.5.7 already declared
|
|
3612
|
+
// it). An unknown typed hook is warn-and-return, not a throw, so we still
|
|
3613
|
+
// wrap registration. Same summariser as session_end — whichever fires
|
|
3614
|
+
// first writes, the other is a no-op. Do not invent a third sink.
|
|
3615
|
+
try {
|
|
3616
|
+
api.on('agent_end', (event?: { sessionId?: string; sessionKey?: string }, ctx?: AgentCtx) => {
|
|
3617
|
+
const endedSession = ctx?.sessionId ?? ctx?.sessionKey ?? event?.sessionId ?? event?.sessionKey ?? null;
|
|
3618
|
+
summariseGuardSession(endedSession, 'openclaw-session-end');
|
|
3619
|
+
});
|
|
3620
|
+
} catch {
|
|
3621
|
+
// Host predates agent_end — session_end is the load-bearing summariser.
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3531
3624
|
// llm_input/llm_output are CONVERSATION hooks: OpenClaw drops them at
|
|
3532
3625
|
// registration for a non-bundled plugin unless the host grants
|
|
3533
3626
|
// plugins.entries.<id>.hooks.allowConversationAccess = true. Registration is
|
package/interceptor.ts
CHANGED
|
@@ -53,8 +53,15 @@ export interface ToolGuardVerdictLike {
|
|
|
53
53
|
action: string;
|
|
54
54
|
reason: string;
|
|
55
55
|
signals: string[];
|
|
56
|
-
/** Rule → matched-span evidence behind `signals` (issue #192).
|
|
57
|
-
|
|
56
|
+
/** Rule → matched-span evidence behind `signals` (issue #192).
|
|
57
|
+
* #184: optional source/line/chain when the match came from folded script. */
|
|
58
|
+
matches?: Array<{
|
|
59
|
+
signal: string;
|
|
60
|
+
span: string;
|
|
61
|
+
source?: string;
|
|
62
|
+
line?: number;
|
|
63
|
+
chain?: string;
|
|
64
|
+
}>;
|
|
58
65
|
/** Files the reviewed-script allowlist exempted from folding (#189). */
|
|
59
66
|
reviewedScripts?: string[];
|
|
60
67
|
}
|
|
@@ -100,6 +107,10 @@ export interface BrokerAuditLike {
|
|
|
100
107
|
judgeConfidence: number | null;
|
|
101
108
|
injectionSuspected: boolean;
|
|
102
109
|
inContext: boolean | null;
|
|
110
|
+
/** #143 residual — "the judge never answered" told apart from "the judge said
|
|
111
|
+
* hold". Audit only; neither field can reach an outcome. */
|
|
112
|
+
judgeTimedOut?: boolean;
|
|
113
|
+
judgeUnavailableReason?: string | null;
|
|
103
114
|
reason: string;
|
|
104
115
|
}
|
|
105
116
|
|
|
@@ -135,11 +146,26 @@ export interface BrokerRuntime {
|
|
|
135
146
|
invoke: ModelInvokerLike,
|
|
136
147
|
opts?: { timeoutMs?: number },
|
|
137
148
|
) => Promise<JudgeResultLike | null>;
|
|
149
|
+
/** #143 residual, and OPTIONAL: this plugin is built across a package
|
|
150
|
+
* boundary, so a main package from before the residual injects `runJudge`
|
|
151
|
+
* alone and the pass below falls back to it. Absent costs an audit field,
|
|
152
|
+
* never a gate. */
|
|
153
|
+
runJudgeDetailed?: (
|
|
154
|
+
req: {
|
|
155
|
+
tool: string;
|
|
156
|
+
toolInput: unknown;
|
|
157
|
+
verdict: { severity: string; action: string; reason: string; signals: string[] };
|
|
158
|
+
sessionSummary?: string;
|
|
159
|
+
},
|
|
160
|
+
invoke: ModelInvokerLike,
|
|
161
|
+
opts?: { timeoutMs?: number },
|
|
162
|
+
) => Promise<{ result: JudgeResultLike | null; timedOut: boolean; error?: string }>;
|
|
138
163
|
brokerDecision: (input: {
|
|
139
164
|
tool: string;
|
|
140
165
|
toolInput: unknown;
|
|
141
166
|
verdict: ToolGuardVerdictLike;
|
|
142
167
|
judge: JudgeResultLike | null;
|
|
168
|
+
judgeMeta?: { timedOut?: boolean; error?: string | null };
|
|
143
169
|
policy?: { allowPreClear: boolean; preClearConfidence: number };
|
|
144
170
|
}) => BrokerDecisionLike;
|
|
145
171
|
timeoutOutcome: (decision: BrokerDecisionLike) => 'approve' | 'deny';
|
|
@@ -202,6 +228,17 @@ export interface InterceptAuditEntry {
|
|
|
202
228
|
escalated?: { by: 'session-taint'; from: string; to: string; reason: string };
|
|
203
229
|
/** Files the reviewed-script allowlist exempted from folding (#189). */
|
|
204
230
|
reviewedScripts?: string[];
|
|
231
|
+
/** #260 — plane origin so the session-guard summariser can find this row. */
|
|
232
|
+
origin?: 'openclaw-interceptor';
|
|
233
|
+
sessionKey?: string;
|
|
234
|
+
/** #224 — binding fields. Present once the host injects `bindAudit`. */
|
|
235
|
+
plane?: 'action_guard' | 'conversation_firewall';
|
|
236
|
+
gatewayInstanceId?: string;
|
|
237
|
+
hookName?: string;
|
|
238
|
+
pluginId?: string;
|
|
239
|
+
nonce?: string;
|
|
240
|
+
seq?: number;
|
|
241
|
+
actionKey?: string;
|
|
205
242
|
}
|
|
206
243
|
|
|
207
244
|
const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'] as const;
|
|
@@ -798,6 +835,15 @@ interface InterceptorOptions {
|
|
|
798
835
|
* limit, so a looping or compromised agent must not be able to spend it
|
|
799
836
|
* without bound. Exhausting it yields no judge, which yields a hold. */
|
|
800
837
|
maxJudgeCallsPerMinute?: number;
|
|
838
|
+
/** #260 — session-guard index. Injected from `shieldcortex/defence`. */
|
|
839
|
+
sessionGuard?: {
|
|
840
|
+
keyFor: (sessionId: string | undefined) => string | null;
|
|
841
|
+
index: (entry: InterceptAuditEntry) => void;
|
|
842
|
+
};
|
|
843
|
+
/** #224 — stamp plane/instance/hook/nonce/seq/actionKey before the row hits
|
|
844
|
+
* disk. Injected from `shieldcortex/defence` at runtime so this plugin does
|
|
845
|
+
* not grow a second schema. Absent = unbound (older installed package). */
|
|
846
|
+
bindAudit?: (entry: InterceptAuditEntry, args?: Record<string, unknown>) => InterceptAuditEntry;
|
|
801
847
|
}
|
|
802
848
|
|
|
803
849
|
/** How many recent tool NAMES the judge is told about. Names only, never
|
|
@@ -820,6 +866,10 @@ export function createInterceptor(
|
|
|
820
866
|
const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
|
|
821
867
|
const log = config.logger ?? { info: console.log, warn: console.warn };
|
|
822
868
|
const onAuditEntry = options?.onAuditEntry;
|
|
869
|
+
let lastSessionId: string | undefined;
|
|
870
|
+
const bindAudit = options?.bindAudit;
|
|
871
|
+
/** Args of the in-flight tool call — used only to mint #224 actionKey. */
|
|
872
|
+
let lastCallArgs: Record<string, unknown> | undefined;
|
|
823
873
|
const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
|
|
824
874
|
const evaluateToolCall = options?.evaluateToolCall;
|
|
825
875
|
const broker = options?.broker;
|
|
@@ -831,8 +881,16 @@ export function createInterceptor(
|
|
|
831
881
|
const recentTools: string[] = [];
|
|
832
882
|
|
|
833
883
|
function emitAudit(entry: InterceptAuditEntry): void {
|
|
834
|
-
|
|
835
|
-
|
|
884
|
+
const sessionKey = options?.sessionGuard?.keyFor(lastSessionId) ?? undefined;
|
|
885
|
+
const withOrigin: InterceptAuditEntry = {
|
|
886
|
+
...entry,
|
|
887
|
+
origin: 'openclaw-interceptor',
|
|
888
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
889
|
+
};
|
|
890
|
+
const bound = bindAudit ? bindAudit(withOrigin, lastCallArgs) : withOrigin;
|
|
891
|
+
writeAuditEntry(bound);
|
|
892
|
+
try { options?.sessionGuard?.index(bound); } catch { /* never wedge the turn */ }
|
|
893
|
+
onAuditEntry?.(bound);
|
|
836
894
|
}
|
|
837
895
|
|
|
838
896
|
function guardAuditBase(toolName: string, v: ToolGuardVerdictLike, preview: string): Omit<InterceptAuditEntry, 'action' | 'outcome'> {
|
|
@@ -902,17 +960,28 @@ export function createInterceptor(
|
|
|
902
960
|
});
|
|
903
961
|
|
|
904
962
|
let judge: JudgeResultLike | null = null;
|
|
963
|
+
// Only set when a judge pass actually ran: "no seam on this build" and
|
|
964
|
+
// "budget spent" are not timeouts, and claiming otherwise would be the
|
|
965
|
+
// same overclaim in the audit that this residual removes from doctor.
|
|
966
|
+
let judgeMeta: { timedOut?: boolean; error?: string | null } | undefined;
|
|
905
967
|
if (invoke && judgeLimiter.shouldAllow()) {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
968
|
+
const request = {
|
|
969
|
+
tool: context.toolName,
|
|
970
|
+
toolInput: context.arguments,
|
|
971
|
+
verdict: { severity: v.severity, action: v.action, reason: v.reason, signals: v.signals },
|
|
972
|
+
sessionSummary: buildSessionSummary(),
|
|
973
|
+
};
|
|
974
|
+
const opts = { timeoutMs: broker.config.judgeTimeoutMs };
|
|
975
|
+
if (typeof broker.runJudgeDetailed === 'function') {
|
|
976
|
+
const detailed = await broker.runJudgeDetailed(request, invoke, opts);
|
|
977
|
+
judge = detailed?.result ?? null;
|
|
978
|
+
judgeMeta = {
|
|
979
|
+
timedOut: detailed?.timedOut === true,
|
|
980
|
+
error: typeof detailed?.error === 'string' ? detailed.error : null,
|
|
981
|
+
};
|
|
982
|
+
} else {
|
|
983
|
+
judge = await broker.runJudge(request, invoke, opts);
|
|
984
|
+
}
|
|
916
985
|
} else if (invoke) {
|
|
917
986
|
log.warn(`[shieldcortex] approval broker: judge budget spent this minute — holding ${context.toolName} for the operator`);
|
|
918
987
|
}
|
|
@@ -922,6 +991,7 @@ export function createInterceptor(
|
|
|
922
991
|
toolInput: context.arguments,
|
|
923
992
|
verdict: v,
|
|
924
993
|
judge,
|
|
994
|
+
...(judgeMeta ? { judgeMeta } : {}),
|
|
925
995
|
policy: {
|
|
926
996
|
allowPreClear: broker.config.allowPreClear,
|
|
927
997
|
preClearConfidence: broker.config.preClearConfidence,
|
|
@@ -1249,6 +1319,8 @@ export function createInterceptor(
|
|
|
1249
1319
|
}
|
|
1250
1320
|
|
|
1251
1321
|
async function handleToolCall(context: ToolCallContext): Promise<void> {
|
|
1322
|
+
lastSessionId = context.sessionId;
|
|
1323
|
+
lastCallArgs = context.arguments;
|
|
1252
1324
|
// Remember the NAME only. This is the entirety of what the approval broker's
|
|
1253
1325
|
// judge will ever learn about the session — see buildSessionSummary.
|
|
1254
1326
|
noteToolForSession(context.toolName);
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.52.0",
|
|
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,
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"llm_output",
|
|
19
19
|
"before_agent_run",
|
|
20
20
|
"before_tool_call",
|
|
21
|
-
"session_end"
|
|
21
|
+
"session_end",
|
|
22
|
+
"agent_end"
|
|
22
23
|
],
|
|
23
24
|
"commands": [
|
|
24
25
|
"shieldcortex-status"
|
package/package.json
CHANGED