@drakon-systems/shieldcortex-realtime 4.50.0 → 4.51.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 +74 -3
- package/dist/interceptor.js +19 -2
- package/dist/openclaw.plugin.json +3 -2
- package/index.ts +91 -3
- package/interceptor.ts +36 -2
- 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) {
|
|
@@ -2910,11 +2943,31 @@ export default {
|
|
|
2910
2943
|
releaseActionLease: typeof defenceMod.releaseToolCallLease === 'function'
|
|
2911
2944
|
? (toolName, args, sessionId) => defenceMod.releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
|
|
2912
2945
|
: undefined,
|
|
2946
|
+
// #260: the session-guard index. Same formula as the Claude Code
|
|
2947
|
+
// hook. Absent on an older dist — then emitAudit still stamps origin
|
|
2948
|
+
// but does not write an index nobody would summarise.
|
|
2949
|
+
sessionGuard: typeof defenceMod.sessionKeyFor === 'function' && typeof defenceMod.appendSessionGuardIndex === 'function'
|
|
2950
|
+
? {
|
|
2951
|
+
keyFor: (sessionId) => defenceMod.sessionKeyFor(sessionId),
|
|
2952
|
+
index: (entry) => {
|
|
2953
|
+
defenceMod.appendSessionGuardIndex({ entry: { ...entry } });
|
|
2954
|
+
},
|
|
2955
|
+
}
|
|
2956
|
+
: undefined,
|
|
2913
2957
|
onAuditEntry: (entry) => syncInterceptEvent(entry, {
|
|
2914
2958
|
cloudApiKey: scConfig.cloudApiKey ?? '',
|
|
2915
2959
|
cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
|
|
2916
2960
|
cloudEnabled: scConfig.cloudEnabled ?? false,
|
|
2917
2961
|
}),
|
|
2962
|
+
bindAudit: typeof defenceMod.attachEnforcementBinding === 'function'
|
|
2963
|
+
? (entry, args) => defenceMod.attachEnforcementBinding(entry, {
|
|
2964
|
+
plane: 'action_guard',
|
|
2965
|
+
hookName: 'before_tool_call',
|
|
2966
|
+
pluginId: 'shieldcortex-realtime',
|
|
2967
|
+
tool: entry.tool,
|
|
2968
|
+
args: args ?? {},
|
|
2969
|
+
})
|
|
2970
|
+
: undefined,
|
|
2918
2971
|
});
|
|
2919
2972
|
const guardState = interceptorConfig.actionGuard?.enabled
|
|
2920
2973
|
? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
|
|
@@ -2976,8 +3029,9 @@ export default {
|
|
|
2976
3029
|
// `before_tool_call`: a registered approval hook changes how OpenClaw
|
|
2977
3030
|
// resolves tool-call approvals for unattended Codex agents, so an
|
|
2978
3031
|
// unattended turn waited 120s on a decision nobody could give. `session_end`
|
|
2979
|
-
// is a notification — it cannot block, approve, or delay anything
|
|
2980
|
-
//
|
|
3032
|
+
// is a notification — it cannot block, approve, or delay anything. It frees
|
|
3033
|
+
// local state and, since #260, best-effort summarises a degraded Action
|
|
3034
|
+
// Guard session. That write is not a decision and cannot stall the host.
|
|
2981
3035
|
try {
|
|
2982
3036
|
api.on('session_end', (event, ctx) => {
|
|
2983
3037
|
interceptorReady?.resetSession();
|
|
@@ -2991,11 +3045,28 @@ export default {
|
|
|
2991
3045
|
// per-session rule, for the same reason.
|
|
2992
3046
|
if (endedSession)
|
|
2993
3047
|
sessionTaint.clear(endedSession);
|
|
3048
|
+
// #260: plane-native summariser. session_end cannot block (#112) —
|
|
3049
|
+
// this is a notification hook. The write is best-effort and
|
|
3050
|
+
// idempotent with agent_end below.
|
|
3051
|
+
summariseGuardSession(endedSession, 'openclaw-session-end');
|
|
2994
3052
|
});
|
|
2995
3053
|
}
|
|
2996
3054
|
catch {
|
|
2997
3055
|
// session_end may not be a supported hook — TTL safety net handles this
|
|
2998
3056
|
}
|
|
3057
|
+
// #260: agent_end exists on the engine floor (2026.5.7 already declared
|
|
3058
|
+
// it). An unknown typed hook is warn-and-return, not a throw, so we still
|
|
3059
|
+
// wrap registration. Same summariser as session_end — whichever fires
|
|
3060
|
+
// first writes, the other is a no-op. Do not invent a third sink.
|
|
3061
|
+
try {
|
|
3062
|
+
api.on('agent_end', (event, ctx) => {
|
|
3063
|
+
const endedSession = ctx?.sessionId ?? ctx?.sessionKey ?? event?.sessionId ?? event?.sessionKey ?? null;
|
|
3064
|
+
summariseGuardSession(endedSession, 'openclaw-session-end');
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
catch {
|
|
3068
|
+
// Host predates agent_end — session_end is the load-bearing summariser.
|
|
3069
|
+
}
|
|
2999
3070
|
// llm_input/llm_output are CONVERSATION hooks: OpenClaw drops them at
|
|
3000
3071
|
// registration for a non-bundled plugin unless the host grants
|
|
3001
3072
|
// 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 {
|
|
@@ -910,6 +925,8 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
910
925
|
throw new Error('ShieldCortex: tool call denied by user');
|
|
911
926
|
}
|
|
912
927
|
async function handleToolCall(context) {
|
|
928
|
+
lastSessionId = context.sessionId;
|
|
929
|
+
lastCallArgs = context.arguments;
|
|
913
930
|
// Remember the NAME only. This is the entirety of what the approval broker's
|
|
914
931
|
// judge will ever learn about the session — see buildSessionSummary.
|
|
915
932
|
noteToolForSession(context.toolName);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.51.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) {
|
|
@@ -3443,11 +3493,31 @@ export default {
|
|
|
3443
3493
|
? (toolName, args, sessionId) =>
|
|
3444
3494
|
(defenceMod as any).releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
|
|
3445
3495
|
: undefined,
|
|
3496
|
+
// #260: the session-guard index. Same formula as the Claude Code
|
|
3497
|
+
// hook. Absent on an older dist — then emitAudit still stamps origin
|
|
3498
|
+
// but does not write an index nobody would summarise.
|
|
3499
|
+
sessionGuard: typeof defenceMod.sessionKeyFor === 'function' && typeof defenceMod.appendSessionGuardIndex === 'function'
|
|
3500
|
+
? {
|
|
3501
|
+
keyFor: (sessionId) => defenceMod.sessionKeyFor!(sessionId),
|
|
3502
|
+
index: (entry) => {
|
|
3503
|
+
defenceMod.appendSessionGuardIndex!({ entry: { ...entry } as Record<string, unknown> });
|
|
3504
|
+
},
|
|
3505
|
+
}
|
|
3506
|
+
: undefined,
|
|
3446
3507
|
onAuditEntry: (entry) => syncInterceptEvent(entry, {
|
|
3447
3508
|
cloudApiKey: (scConfig as any).cloudApiKey ?? '',
|
|
3448
3509
|
cloudBaseUrl: (scConfig as any).cloudBaseUrl ?? 'https://api.shieldcortex.ai',
|
|
3449
3510
|
cloudEnabled: (scConfig as any).cloudEnabled ?? false,
|
|
3450
3511
|
}),
|
|
3512
|
+
bindAudit: typeof (defenceMod as any).attachEnforcementBinding === 'function'
|
|
3513
|
+
? (entry, args) => (defenceMod as any).attachEnforcementBinding(entry, {
|
|
3514
|
+
plane: 'action_guard',
|
|
3515
|
+
hookName: 'before_tool_call',
|
|
3516
|
+
pluginId: 'shieldcortex-realtime',
|
|
3517
|
+
tool: entry.tool,
|
|
3518
|
+
args: args ?? {},
|
|
3519
|
+
}) as typeof entry
|
|
3520
|
+
: undefined,
|
|
3451
3521
|
});
|
|
3452
3522
|
const guardState = interceptorConfig.actionGuard?.enabled
|
|
3453
3523
|
? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
|
|
@@ -3509,8 +3579,9 @@ export default {
|
|
|
3509
3579
|
// `before_tool_call`: a registered approval hook changes how OpenClaw
|
|
3510
3580
|
// resolves tool-call approvals for unattended Codex agents, so an
|
|
3511
3581
|
// unattended turn waited 120s on a decision nobody could give. `session_end`
|
|
3512
|
-
// is a notification — it cannot block, approve, or delay anything
|
|
3513
|
-
//
|
|
3582
|
+
// is a notification — it cannot block, approve, or delay anything. It frees
|
|
3583
|
+
// local state and, since #260, best-effort summarises a degraded Action
|
|
3584
|
+
// Guard session. That write is not a decision and cannot stall the host.
|
|
3514
3585
|
try {
|
|
3515
3586
|
api.on('session_end', (event?: { sessionId?: string; sessionKey?: string }, ctx?: AgentCtx) => {
|
|
3516
3587
|
interceptorReady?.resetSession();
|
|
@@ -3523,11 +3594,28 @@ export default {
|
|
|
3523
3594
|
// #233: a taint must not outlive the conversation that earned it. Same
|
|
3524
3595
|
// per-session rule, for the same reason.
|
|
3525
3596
|
if (endedSession) sessionTaint.clear(endedSession);
|
|
3597
|
+
// #260: plane-native summariser. session_end cannot block (#112) —
|
|
3598
|
+
// this is a notification hook. The write is best-effort and
|
|
3599
|
+
// idempotent with agent_end below.
|
|
3600
|
+
summariseGuardSession(endedSession, 'openclaw-session-end');
|
|
3526
3601
|
});
|
|
3527
3602
|
} catch {
|
|
3528
3603
|
// session_end may not be a supported hook — TTL safety net handles this
|
|
3529
3604
|
}
|
|
3530
3605
|
|
|
3606
|
+
// #260: agent_end exists on the engine floor (2026.5.7 already declared
|
|
3607
|
+
// it). An unknown typed hook is warn-and-return, not a throw, so we still
|
|
3608
|
+
// wrap registration. Same summariser as session_end — whichever fires
|
|
3609
|
+
// first writes, the other is a no-op. Do not invent a third sink.
|
|
3610
|
+
try {
|
|
3611
|
+
api.on('agent_end', (event?: { sessionId?: string; sessionKey?: string }, ctx?: AgentCtx) => {
|
|
3612
|
+
const endedSession = ctx?.sessionId ?? ctx?.sessionKey ?? event?.sessionId ?? event?.sessionKey ?? null;
|
|
3613
|
+
summariseGuardSession(endedSession, 'openclaw-session-end');
|
|
3614
|
+
});
|
|
3615
|
+
} catch {
|
|
3616
|
+
// Host predates agent_end — session_end is the load-bearing summariser.
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3531
3619
|
// llm_input/llm_output are CONVERSATION hooks: OpenClaw drops them at
|
|
3532
3620
|
// registration for a non-bundled plugin unless the host grants
|
|
3533
3621
|
// plugins.entries.<id>.hooks.allowConversationAccess = true. Registration is
|
package/interceptor.ts
CHANGED
|
@@ -202,6 +202,17 @@ export interface InterceptAuditEntry {
|
|
|
202
202
|
escalated?: { by: 'session-taint'; from: string; to: string; reason: string };
|
|
203
203
|
/** Files the reviewed-script allowlist exempted from folding (#189). */
|
|
204
204
|
reviewedScripts?: string[];
|
|
205
|
+
/** #260 — plane origin so the session-guard summariser can find this row. */
|
|
206
|
+
origin?: 'openclaw-interceptor';
|
|
207
|
+
sessionKey?: string;
|
|
208
|
+
/** #224 — binding fields. Present once the host injects `bindAudit`. */
|
|
209
|
+
plane?: 'action_guard' | 'conversation_firewall';
|
|
210
|
+
gatewayInstanceId?: string;
|
|
211
|
+
hookName?: string;
|
|
212
|
+
pluginId?: string;
|
|
213
|
+
nonce?: string;
|
|
214
|
+
seq?: number;
|
|
215
|
+
actionKey?: string;
|
|
205
216
|
}
|
|
206
217
|
|
|
207
218
|
const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'] as const;
|
|
@@ -798,6 +809,15 @@ interface InterceptorOptions {
|
|
|
798
809
|
* limit, so a looping or compromised agent must not be able to spend it
|
|
799
810
|
* without bound. Exhausting it yields no judge, which yields a hold. */
|
|
800
811
|
maxJudgeCallsPerMinute?: number;
|
|
812
|
+
/** #260 — session-guard index. Injected from `shieldcortex/defence`. */
|
|
813
|
+
sessionGuard?: {
|
|
814
|
+
keyFor: (sessionId: string | undefined) => string | null;
|
|
815
|
+
index: (entry: InterceptAuditEntry) => void;
|
|
816
|
+
};
|
|
817
|
+
/** #224 — stamp plane/instance/hook/nonce/seq/actionKey before the row hits
|
|
818
|
+
* disk. Injected from `shieldcortex/defence` at runtime so this plugin does
|
|
819
|
+
* not grow a second schema. Absent = unbound (older installed package). */
|
|
820
|
+
bindAudit?: (entry: InterceptAuditEntry, args?: Record<string, unknown>) => InterceptAuditEntry;
|
|
801
821
|
}
|
|
802
822
|
|
|
803
823
|
/** How many recent tool NAMES the judge is told about. Names only, never
|
|
@@ -820,6 +840,10 @@ export function createInterceptor(
|
|
|
820
840
|
const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
|
|
821
841
|
const log = config.logger ?? { info: console.log, warn: console.warn };
|
|
822
842
|
const onAuditEntry = options?.onAuditEntry;
|
|
843
|
+
let lastSessionId: string | undefined;
|
|
844
|
+
const bindAudit = options?.bindAudit;
|
|
845
|
+
/** Args of the in-flight tool call — used only to mint #224 actionKey. */
|
|
846
|
+
let lastCallArgs: Record<string, unknown> | undefined;
|
|
823
847
|
const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
|
|
824
848
|
const evaluateToolCall = options?.evaluateToolCall;
|
|
825
849
|
const broker = options?.broker;
|
|
@@ -831,8 +855,16 @@ export function createInterceptor(
|
|
|
831
855
|
const recentTools: string[] = [];
|
|
832
856
|
|
|
833
857
|
function emitAudit(entry: InterceptAuditEntry): void {
|
|
834
|
-
|
|
835
|
-
|
|
858
|
+
const sessionKey = options?.sessionGuard?.keyFor(lastSessionId) ?? undefined;
|
|
859
|
+
const withOrigin: InterceptAuditEntry = {
|
|
860
|
+
...entry,
|
|
861
|
+
origin: 'openclaw-interceptor',
|
|
862
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
863
|
+
};
|
|
864
|
+
const bound = bindAudit ? bindAudit(withOrigin, lastCallArgs) : withOrigin;
|
|
865
|
+
writeAuditEntry(bound);
|
|
866
|
+
try { options?.sessionGuard?.index(bound); } catch { /* never wedge the turn */ }
|
|
867
|
+
onAuditEntry?.(bound);
|
|
836
868
|
}
|
|
837
869
|
|
|
838
870
|
function guardAuditBase(toolName: string, v: ToolGuardVerdictLike, preview: string): Omit<InterceptAuditEntry, 'action' | 'outcome'> {
|
|
@@ -1249,6 +1281,8 @@ export function createInterceptor(
|
|
|
1249
1281
|
}
|
|
1250
1282
|
|
|
1251
1283
|
async function handleToolCall(context: ToolCallContext): Promise<void> {
|
|
1284
|
+
lastSessionId = context.sessionId;
|
|
1285
|
+
lastCallArgs = context.arguments;
|
|
1252
1286
|
// Remember the NAME only. This is the entirety of what the approval broker's
|
|
1253
1287
|
// judge will ever learn about the session — see buildSessionSummary.
|
|
1254
1288
|
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.51.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