@drakon-systems/shieldcortex-realtime 4.49.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 +84 -3
- package/dist/interceptor.js +57 -2
- package/dist/openclaw.plugin.json +3 -2
- package/index.ts +103 -3
- package/interceptor.ts +93 -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) {
|
|
@@ -2900,11 +2933,41 @@ export default {
|
|
|
2900
2933
|
const rec = sessionId ? sessionTaint.get(sessionId) : null;
|
|
2901
2934
|
return rec ? { reason: rec.reason } : null;
|
|
2902
2935
|
},
|
|
2936
|
+
// #227: session action lease — the fs-backed shared implementation,
|
|
2937
|
+
// injected through the same runtime seam as evaluateToolCall. Older
|
|
2938
|
+
// installed packages without the export simply leave the option
|
|
2939
|
+
// undefined (no lease plane — the capability-honesty surface says so).
|
|
2940
|
+
checkActionLease: typeof defenceMod.evaluateToolCallLease === 'function'
|
|
2941
|
+
? (toolName, args, sessionId) => defenceMod.evaluateToolCallLease(toolName, args, { self: sessionId ?? '' })
|
|
2942
|
+
: undefined,
|
|
2943
|
+
releaseActionLease: typeof defenceMod.releaseToolCallLease === 'function'
|
|
2944
|
+
? (toolName, args, sessionId) => defenceMod.releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
|
|
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,
|
|
2903
2957
|
onAuditEntry: (entry) => syncInterceptEvent(entry, {
|
|
2904
2958
|
cloudApiKey: scConfig.cloudApiKey ?? '',
|
|
2905
2959
|
cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
|
|
2906
2960
|
cloudEnabled: scConfig.cloudEnabled ?? false,
|
|
2907
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,
|
|
2908
2971
|
});
|
|
2909
2972
|
const guardState = interceptorConfig.actionGuard?.enabled
|
|
2910
2973
|
? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
|
|
@@ -2966,8 +3029,9 @@ export default {
|
|
|
2966
3029
|
// `before_tool_call`: a registered approval hook changes how OpenClaw
|
|
2967
3030
|
// resolves tool-call approvals for unattended Codex agents, so an
|
|
2968
3031
|
// unattended turn waited 120s on a decision nobody could give. `session_end`
|
|
2969
|
-
// is a notification — it cannot block, approve, or delay anything
|
|
2970
|
-
//
|
|
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.
|
|
2971
3035
|
try {
|
|
2972
3036
|
api.on('session_end', (event, ctx) => {
|
|
2973
3037
|
interceptorReady?.resetSession();
|
|
@@ -2981,11 +3045,28 @@ export default {
|
|
|
2981
3045
|
// per-session rule, for the same reason.
|
|
2982
3046
|
if (endedSession)
|
|
2983
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');
|
|
2984
3052
|
});
|
|
2985
3053
|
}
|
|
2986
3054
|
catch {
|
|
2987
3055
|
// session_end may not be a supported hook — TTL safety net handles this
|
|
2988
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
|
+
}
|
|
2989
3070
|
// llm_input/llm_output are CONVERSATION hooks: OpenClaw drops them at
|
|
2990
3071
|
// registration for a non-bundled plugin unless the host grants
|
|
2991
3072
|
// plugins.entries.<id>.hooks.allowConversationAccess = true. Registration is
|
package/dist/interceptor.js
CHANGED
|
@@ -203,6 +203,8 @@ const FALLBACK_DANGEROUS_PATTERNS = [
|
|
|
203
203
|
{ re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
|
|
204
204
|
// Guard's own approval store (#118): agent-side writes here mint approvals.
|
|
205
205
|
{ re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
|
|
206
|
+
// Session-lease ledger + store (#227): a freeze an agent can edit is not a freeze.
|
|
207
|
+
{ re: /\.shieldcortex[\\/]+(?:DECISIONS\.md|leases)\b/i, signal: 'touch-decisions-ledger' },
|
|
206
208
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
|
|
207
209
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
|
|
208
210
|
{ re: /\b(?:base64|openssl|xxd|cat|http)\b[^\n|]*\|(?:[^\n|]*\|)*\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:bash|sh|zsh|ksh|python\d?|perl|ruby|node)\b(?:\s+-)?\s*(?:[;&|\n]|$)/i, signal: 'decode-pipe-to-shell' },
|
|
@@ -507,6 +509,10 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
507
509
|
const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
|
|
508
510
|
const log = config.logger ?? { info: console.log, warn: console.warn };
|
|
509
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;
|
|
510
516
|
const actionGuardCfg = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
|
|
511
517
|
const evaluateToolCall = options?.evaluateToolCall;
|
|
512
518
|
const broker = options?.broker;
|
|
@@ -517,8 +523,19 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
517
523
|
/** Bare tool names seen this session, newest last. See buildSessionSummary. */
|
|
518
524
|
const recentTools = [];
|
|
519
525
|
function emitAudit(entry) {
|
|
520
|
-
|
|
521
|
-
|
|
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);
|
|
522
539
|
}
|
|
523
540
|
function guardAuditBase(toolName, v, preview) {
|
|
524
541
|
return {
|
|
@@ -680,6 +697,34 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
680
697
|
async function runActionGuard(context) {
|
|
681
698
|
if (!actionGuardCfg.enabled)
|
|
682
699
|
return;
|
|
700
|
+
// Session action lease (#227) — EARLY, before the guard evaluator (which may
|
|
701
|
+
// be unwired or throw). A freeze is a HARD control and must bind regardless
|
|
702
|
+
// of the guard's own state; running it only after a successful evaluation
|
|
703
|
+
// would let a frozen action through the guard-unavailable path. Unscoped
|
|
704
|
+
// calls return null and cost nothing; a THROW is treated as no-lease (a
|
|
705
|
+
// broken lease layer must not deny everything); state unreadability fails
|
|
706
|
+
// closed to 'unknown' inside the store.
|
|
707
|
+
let leaseGate = null;
|
|
708
|
+
try {
|
|
709
|
+
leaseGate = options?.checkActionLease?.(context.toolName, context.arguments || {}, context.sessionId) ?? null;
|
|
710
|
+
if (leaseGate?.ledgerChanged) {
|
|
711
|
+
log.warn(`[shieldcortex] DECISIONS.md changed since last read (${leaseGate.ledgerChanged.fromHash.slice(0, 12)} → ${leaseGate.ledgerChanged.toHash.slice(0, 12)}) — tamper evidence, review the ledger`);
|
|
712
|
+
}
|
|
713
|
+
if (leaseGate && leaseGate.decision.verdict !== 'allow') {
|
|
714
|
+
emitAudit({
|
|
715
|
+
...guardAuditBase(context.toolName, { decision: 'block', severity: 'high', family: 'exec', action: `session-lease:${leaseGate.scope}`, reason: leaseGate.decision.reason, signals: ['session-lease', leaseGate.decision.verdict] }, `${context.toolName} :: ${summariseToolArgs(context.arguments)}`),
|
|
716
|
+
action: 'auto_deny', outcome: 'auto_denied',
|
|
717
|
+
});
|
|
718
|
+
log.warn(`[shieldcortex] action-guard SESSION-LEASE refused ${context.toolName} [${leaseGate.scope}/${leaseGate.decision.verdict}]: ${leaseGate.decision.reason}`);
|
|
719
|
+
throw new Error(`ShieldCortex: tool call blocked — ${leaseGate.decision.reason}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
catch (err) {
|
|
723
|
+
// A ShieldCortex refusal must propagate; a lease-layer malfunction must not.
|
|
724
|
+
if (err instanceof Error && err.message.startsWith('ShieldCortex:'))
|
|
725
|
+
throw err;
|
|
726
|
+
leaseGate = null;
|
|
727
|
+
}
|
|
683
728
|
if (typeof evaluateToolCall !== 'function') {
|
|
684
729
|
handleGuardUnavailable(context, 'evaluateToolCall not wired');
|
|
685
730
|
return;
|
|
@@ -757,6 +802,14 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
757
802
|
const severity = v.severity === 'catastrophic' ? 'critical' : 'high';
|
|
758
803
|
// Catastrophic / exfil — hard block, always enforced when the guard is enabled.
|
|
759
804
|
if (v.decision === 'block') {
|
|
805
|
+
// #227: release any lease this call minted early — a blocked action must
|
|
806
|
+
// not leave a hold on that scope (self-heals at TTL if release fails).
|
|
807
|
+
if (leaseGate?.acquired) {
|
|
808
|
+
try {
|
|
809
|
+
options?.releaseActionLease?.(context.toolName, context.arguments || {}, context.sessionId);
|
|
810
|
+
}
|
|
811
|
+
catch { /* self-heals */ }
|
|
812
|
+
}
|
|
760
813
|
emitAudit({ ...base, action: 'auto_deny', outcome: 'auto_denied' });
|
|
761
814
|
// Surface the block to the gateway log (journald). Blocks are recorded in
|
|
762
815
|
// the ShieldCortex audit jsonl, but were otherwise invisible to an operator
|
|
@@ -872,6 +925,8 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
872
925
|
throw new Error('ShieldCortex: tool call denied by user');
|
|
873
926
|
}
|
|
874
927
|
async function handleToolCall(context) {
|
|
928
|
+
lastSessionId = context.sessionId;
|
|
929
|
+
lastCallArgs = context.arguments;
|
|
875
930
|
// Remember the NAME only. This is the entirety of what the approval broker's
|
|
876
931
|
// judge will ever learn about the session — see buildSessionSummary.
|
|
877
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) {
|
|
@@ -3431,11 +3481,43 @@ export default {
|
|
|
3431
3481
|
const rec = sessionId ? sessionTaint.get(sessionId) : null;
|
|
3432
3482
|
return rec ? { reason: rec.reason } : null;
|
|
3433
3483
|
},
|
|
3484
|
+
// #227: session action lease — the fs-backed shared implementation,
|
|
3485
|
+
// injected through the same runtime seam as evaluateToolCall. Older
|
|
3486
|
+
// installed packages without the export simply leave the option
|
|
3487
|
+
// undefined (no lease plane — the capability-honesty surface says so).
|
|
3488
|
+
checkActionLease: typeof (defenceMod as any).evaluateToolCallLease === 'function'
|
|
3489
|
+
? (toolName, args, sessionId) =>
|
|
3490
|
+
(defenceMod as any).evaluateToolCallLease(toolName, args, { self: sessionId ?? '' })
|
|
3491
|
+
: undefined,
|
|
3492
|
+
releaseActionLease: typeof (defenceMod as any).releaseToolCallLease === 'function'
|
|
3493
|
+
? (toolName, args, sessionId) =>
|
|
3494
|
+
(defenceMod as any).releaseToolCallLease(toolName, args, { self: sessionId ?? '' })
|
|
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,
|
|
3434
3507
|
onAuditEntry: (entry) => syncInterceptEvent(entry, {
|
|
3435
3508
|
cloudApiKey: (scConfig as any).cloudApiKey ?? '',
|
|
3436
3509
|
cloudBaseUrl: (scConfig as any).cloudBaseUrl ?? 'https://api.shieldcortex.ai',
|
|
3437
3510
|
cloudEnabled: (scConfig as any).cloudEnabled ?? false,
|
|
3438
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,
|
|
3439
3521
|
});
|
|
3440
3522
|
const guardState = interceptorConfig.actionGuard?.enabled
|
|
3441
3523
|
? (interceptorConfig.actionGuard.enforce ? 'Action Guard: enforce' : 'Action Guard: warn')
|
|
@@ -3497,8 +3579,9 @@ export default {
|
|
|
3497
3579
|
// `before_tool_call`: a registered approval hook changes how OpenClaw
|
|
3498
3580
|
// resolves tool-call approvals for unattended Codex agents, so an
|
|
3499
3581
|
// unattended turn waited 120s on a decision nobody could give. `session_end`
|
|
3500
|
-
// is a notification — it cannot block, approve, or delay anything
|
|
3501
|
-
//
|
|
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.
|
|
3502
3585
|
try {
|
|
3503
3586
|
api.on('session_end', (event?: { sessionId?: string; sessionKey?: string }, ctx?: AgentCtx) => {
|
|
3504
3587
|
interceptorReady?.resetSession();
|
|
@@ -3511,11 +3594,28 @@ export default {
|
|
|
3511
3594
|
// #233: a taint must not outlive the conversation that earned it. Same
|
|
3512
3595
|
// per-session rule, for the same reason.
|
|
3513
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');
|
|
3514
3601
|
});
|
|
3515
3602
|
} catch {
|
|
3516
3603
|
// session_end may not be a supported hook — TTL safety net handles this
|
|
3517
3604
|
}
|
|
3518
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
|
+
|
|
3519
3619
|
// llm_input/llm_output are CONVERSATION hooks: OpenClaw drops them at
|
|
3520
3620
|
// registration for a non-bundled plugin unless the host grants
|
|
3521
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;
|
|
@@ -438,6 +449,8 @@ const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string }> = [
|
|
|
438
449
|
{ re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
|
|
439
450
|
// Guard's own approval store (#118): agent-side writes here mint approvals.
|
|
440
451
|
{ re: /\.shieldcortex[\\/]+approvals\b/i, signal: 'touch-approval-store' },
|
|
452
|
+
// Session-lease ledger + store (#227): a freeze an agent can edit is not a freeze.
|
|
453
|
+
{ re: /\.shieldcortex[\\/]+(?:DECISIONS\.md|leases)\b/i, signal: 'touch-decisions-ledger' },
|
|
441
454
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
|
|
442
455
|
{ re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
|
|
443
456
|
{ re: /\b(?:base64|openssl|xxd|cat|http)\b[^\n|]*\|(?:[^\n|]*\|)*\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:bash|sh|zsh|ksh|python\d?|perl|ruby|node)\b(?:\s+-)?\s*(?:[;&|\n]|$)/i, signal: 'decode-pipe-to-shell' },
|
|
@@ -770,6 +783,24 @@ interface InterceptorOptions {
|
|
|
770
783
|
* (or throwing) means no escalation — a broken scanner must never become a
|
|
771
784
|
* new source of denials. */
|
|
772
785
|
sessionTaint?: (sessionId: string | undefined) => { reason: string } | null;
|
|
786
|
+
/** #227: session action lease — injected from `shieldcortex/defence` at
|
|
787
|
+
* runtime (evaluateToolCallLease). Null for unscoped calls (the common
|
|
788
|
+
* case); a non-allow decision for a scoped call is a refusal that must
|
|
789
|
+
* precede every approval affordance. A THROW is treated as no-lease; state
|
|
790
|
+
* unreadability fails closed INSIDE the implementation. */
|
|
791
|
+
checkActionLease?: (
|
|
792
|
+
toolName: string,
|
|
793
|
+
args: Record<string, unknown>,
|
|
794
|
+
sessionId: string | undefined,
|
|
795
|
+
) => {
|
|
796
|
+
scope: string;
|
|
797
|
+
decision: { verdict: string; reason: string };
|
|
798
|
+
acquired?: boolean;
|
|
799
|
+
ledgerChanged?: { fromHash: string; toHash: string };
|
|
800
|
+
} | null;
|
|
801
|
+
/** #227: release a lease this call minted early, when the guard then blocks
|
|
802
|
+
* the action. Best-effort; a hold self-heals at its TTL if this is absent. */
|
|
803
|
+
releaseActionLease?: (toolName: string, args: Record<string, unknown>, sessionId: string | undefined) => void;
|
|
773
804
|
/** Approval broker (#143), injected from `shieldcortex/defence` at runtime.
|
|
774
805
|
* Absent, or present with `config.enabled: false`, means no model is ever
|
|
775
806
|
* consulted and the guard behaves exactly as it did before #143. */
|
|
@@ -778,6 +809,15 @@ interface InterceptorOptions {
|
|
|
778
809
|
* limit, so a looping or compromised agent must not be able to spend it
|
|
779
810
|
* without bound. Exhausting it yields no judge, which yields a hold. */
|
|
780
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;
|
|
781
821
|
}
|
|
782
822
|
|
|
783
823
|
/** How many recent tool NAMES the judge is told about. Names only, never
|
|
@@ -800,6 +840,10 @@ export function createInterceptor(
|
|
|
800
840
|
const rateLimiter = new RateLimiter(options?.maxPromptsPerMinute ?? 5);
|
|
801
841
|
const log = config.logger ?? { info: console.log, warn: console.warn };
|
|
802
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;
|
|
803
847
|
const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
|
|
804
848
|
const evaluateToolCall = options?.evaluateToolCall;
|
|
805
849
|
const broker = options?.broker;
|
|
@@ -811,8 +855,16 @@ export function createInterceptor(
|
|
|
811
855
|
const recentTools: string[] = [];
|
|
812
856
|
|
|
813
857
|
function emitAudit(entry: InterceptAuditEntry): void {
|
|
814
|
-
|
|
815
|
-
|
|
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);
|
|
816
868
|
}
|
|
817
869
|
|
|
818
870
|
function guardAuditBase(toolName: string, v: ToolGuardVerdictLike, preview: string): Omit<InterceptAuditEntry, 'action' | 'outcome'> {
|
|
@@ -983,6 +1035,38 @@ export function createInterceptor(
|
|
|
983
1035
|
async function runActionGuard(context: ToolCallContext): Promise<void> {
|
|
984
1036
|
if (!actionGuardCfg.enabled) return;
|
|
985
1037
|
|
|
1038
|
+
// Session action lease (#227) — EARLY, before the guard evaluator (which may
|
|
1039
|
+
// be unwired or throw). A freeze is a HARD control and must bind regardless
|
|
1040
|
+
// of the guard's own state; running it only after a successful evaluation
|
|
1041
|
+
// would let a frozen action through the guard-unavailable path. Unscoped
|
|
1042
|
+
// calls return null and cost nothing; a THROW is treated as no-lease (a
|
|
1043
|
+
// broken lease layer must not deny everything); state unreadability fails
|
|
1044
|
+
// closed to 'unknown' inside the store.
|
|
1045
|
+
let leaseGate: {
|
|
1046
|
+
scope: string; decision: { verdict: string; reason: string };
|
|
1047
|
+
acquired?: boolean; ledgerChanged?: { fromHash: string; toHash: string };
|
|
1048
|
+
} | null = null;
|
|
1049
|
+
try {
|
|
1050
|
+
leaseGate = options?.checkActionLease?.(context.toolName, context.arguments || {}, context.sessionId) ?? null;
|
|
1051
|
+
if (leaseGate?.ledgerChanged) {
|
|
1052
|
+
log.warn(
|
|
1053
|
+
`[shieldcortex] DECISIONS.md changed since last read (${leaseGate.ledgerChanged.fromHash.slice(0, 12)} → ${leaseGate.ledgerChanged.toHash.slice(0, 12)}) — tamper evidence, review the ledger`,
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
if (leaseGate && leaseGate.decision.verdict !== 'allow') {
|
|
1057
|
+
emitAudit({
|
|
1058
|
+
...guardAuditBase(context.toolName, { decision: 'block', severity: 'high', family: 'exec', action: `session-lease:${leaseGate.scope}`, reason: leaseGate.decision.reason, signals: ['session-lease', leaseGate.decision.verdict] } as ToolGuardVerdictLike, `${context.toolName} :: ${summariseToolArgs(context.arguments)}`),
|
|
1059
|
+
action: 'auto_deny', outcome: 'auto_denied',
|
|
1060
|
+
});
|
|
1061
|
+
log.warn(`[shieldcortex] action-guard SESSION-LEASE refused ${context.toolName} [${leaseGate.scope}/${leaseGate.decision.verdict}]: ${leaseGate.decision.reason}`);
|
|
1062
|
+
throw new Error(`ShieldCortex: tool call blocked — ${leaseGate.decision.reason}`);
|
|
1063
|
+
}
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
// A ShieldCortex refusal must propagate; a lease-layer malfunction must not.
|
|
1066
|
+
if (err instanceof Error && err.message.startsWith('ShieldCortex:')) throw err;
|
|
1067
|
+
leaseGate = null;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
986
1070
|
if (typeof evaluateToolCall !== 'function') {
|
|
987
1071
|
handleGuardUnavailable(context, 'evaluateToolCall not wired');
|
|
988
1072
|
return;
|
|
@@ -1065,6 +1149,11 @@ export function createInterceptor(
|
|
|
1065
1149
|
|
|
1066
1150
|
// Catastrophic / exfil — hard block, always enforced when the guard is enabled.
|
|
1067
1151
|
if (v.decision === 'block') {
|
|
1152
|
+
// #227: release any lease this call minted early — a blocked action must
|
|
1153
|
+
// not leave a hold on that scope (self-heals at TTL if release fails).
|
|
1154
|
+
if (leaseGate?.acquired) {
|
|
1155
|
+
try { options?.releaseActionLease?.(context.toolName, context.arguments || {}, context.sessionId); } catch { /* self-heals */ }
|
|
1156
|
+
}
|
|
1068
1157
|
emitAudit({ ...base, action: 'auto_deny', outcome: 'auto_denied' });
|
|
1069
1158
|
// Surface the block to the gateway log (journald). Blocks are recorded in
|
|
1070
1159
|
// the ShieldCortex audit jsonl, but were otherwise invisible to an operator
|
|
@@ -1192,6 +1281,8 @@ export function createInterceptor(
|
|
|
1192
1281
|
}
|
|
1193
1282
|
|
|
1194
1283
|
async function handleToolCall(context: ToolCallContext): Promise<void> {
|
|
1284
|
+
lastSessionId = context.sessionId;
|
|
1285
|
+
lastCallArgs = context.arguments;
|
|
1195
1286
|
// Remember the NAME only. This is the entirety of what the approval broker's
|
|
1196
1287
|
// judge will ever learn about the session — see buildSessionSummary.
|
|
1197
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