@drakon-systems/shieldcortex-realtime 4.47.35 → 4.47.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/conversation-access.js +69 -0
- package/dist/conversation-trust.js +72 -0
- package/dist/index.js +86 -6
- package/dist/interceptor.js +40 -1
- package/dist/openclaw.plugin.json +1 -1
- package/dist/session-taint.js +130 -0
- package/index.ts +92 -5
- package/interceptor.ts +55 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #225 phase 1 — the plugin-side copy of the conversation-access grant reader.
|
|
3
|
+
*
|
|
4
|
+
* Duplicated from `src/integrations/openclaw-conversation-access.ts` because
|
|
5
|
+
* the plugin is a separate build with no `src/` imports (same boundary as the
|
|
6
|
+
* reviewed-script allowlist and the fallback guard patterns). The two copies
|
|
7
|
+
* are held together by a parity test — see
|
|
8
|
+
* `src/__tests__/conversation-access-honesty-225.test.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Why the plugin needs it at all: the startup line announced
|
|
11
|
+
* `registered (llm_input + llm_output + …)` unconditionally, on hosts where
|
|
12
|
+
* OpenClaw had just dropped both for want of the grant. The plugin cannot
|
|
13
|
+
* observe that rejection (the host emits it as its own diagnostic and `api.on`
|
|
14
|
+
* returns void), so it must read the same config the host reads and describe
|
|
15
|
+
* only what will actually be live.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
/** Pure: evaluate a parsed openclaw.json object. */
|
|
20
|
+
export function evaluateConversationAccess(config, pluginId) {
|
|
21
|
+
if (!config || typeof config !== 'object') {
|
|
22
|
+
return { granted: false, readable: false, entryPresent: false };
|
|
23
|
+
}
|
|
24
|
+
const plugins = config.plugins;
|
|
25
|
+
const entries = plugins && typeof plugins === 'object'
|
|
26
|
+
? plugins.entries
|
|
27
|
+
: undefined;
|
|
28
|
+
const entry = entries && typeof entries === 'object'
|
|
29
|
+
? entries[pluginId]
|
|
30
|
+
: undefined;
|
|
31
|
+
if (!entry || typeof entry !== 'object') {
|
|
32
|
+
return { granted: false, readable: true, entryPresent: false };
|
|
33
|
+
}
|
|
34
|
+
const hooks = entry.hooks;
|
|
35
|
+
const raw = hooks && typeof hooks === 'object'
|
|
36
|
+
? hooks.allowConversationAccess
|
|
37
|
+
: undefined;
|
|
38
|
+
// Strict `true`, matching OpenClaw's own `!== true` comparison exactly.
|
|
39
|
+
return { granted: raw === true, readable: true, entryPresent: true };
|
|
40
|
+
}
|
|
41
|
+
/** Read `~/.openclaw/openclaw.json` and evaluate the grant. Never throws. */
|
|
42
|
+
export function readConversationAccess(home, pluginId) {
|
|
43
|
+
try {
|
|
44
|
+
const raw = readFileSync(path.join(home, '.openclaw', 'openclaw.json'), 'utf-8');
|
|
45
|
+
return evaluateConversationAccess(JSON.parse(raw), pluginId);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { granted: false, readable: false, entryPresent: false };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The hooks this plugin can honestly claim at startup. Conversation hooks are
|
|
53
|
+
* only listed when the host will actually keep them.
|
|
54
|
+
*/
|
|
55
|
+
export function describeRegisteredHooks(opts) {
|
|
56
|
+
const live = [];
|
|
57
|
+
if (opts.access.granted)
|
|
58
|
+
live.push('llm_input', 'llm_output');
|
|
59
|
+
if (opts.beforeToolCallRegistered)
|
|
60
|
+
live.push('before_tool_call');
|
|
61
|
+
live.push('/shieldcortex-status');
|
|
62
|
+
let line = live.join(' + ');
|
|
63
|
+
if (!opts.access.granted) {
|
|
64
|
+
line += opts.access.readable
|
|
65
|
+
? ' — conversation scanning INACTIVE (llm_input/llm_output dropped by OpenClaw: set plugins.entries.shieldcortex-realtime.hooks.allowConversationAccess=true to enable)'
|
|
66
|
+
: ' — conversation scanning state UNKNOWN (could not read openclaw.json)';
|
|
67
|
+
}
|
|
68
|
+
return line;
|
|
69
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source trust for conversation content.
|
|
3
|
+
*
|
|
4
|
+
* The operator's own typing is the highest-trust input that exists. Treating it
|
|
5
|
+
* as a possible attack is nearly pure cost: it produces false alarms, and false
|
|
6
|
+
* alarms are how a security control gets switched off. "Delete the old logs"
|
|
7
|
+
* typed by the owner is an INSTRUCTION, not an injection.
|
|
8
|
+
*
|
|
9
|
+
* The rule this encodes:
|
|
10
|
+
*
|
|
11
|
+
* the human speaks instructions; everything else is data.
|
|
12
|
+
*
|
|
13
|
+
* Two things it deliberately does NOT do:
|
|
14
|
+
*
|
|
15
|
+
* 1. **It does not trust the channel.** Telegram is a trusted pipe, but a web
|
|
16
|
+
* page pasted into Telegram is untrusted content arriving through it.
|
|
17
|
+
* Riding a trusted channel is prompt injection's entire trick, so only the
|
|
18
|
+
* SENDER can confer trust — never the transport.
|
|
19
|
+
*
|
|
20
|
+
* 2. **It does not treat agent-to-agent traffic as trusted.** This is the
|
|
21
|
+
* counterintuitive one. If agent A reads a poisoned issue and relays it to
|
|
22
|
+
* agent B over a closed platform, the platform being closed is exactly what
|
|
23
|
+
* lets the injection spread — a confused deputy with good transport. Anything
|
|
24
|
+
* whose sender is not provably the owner is data.
|
|
25
|
+
*
|
|
26
|
+
* ShieldCortex's memory side already says this: a sub-agent write has its
|
|
27
|
+
* trust cut per level away from the human and lands in a hold band. This is
|
|
28
|
+
* the same principle applied to the conversation path.
|
|
29
|
+
*
|
|
30
|
+
* IMPORTANT — trust gates the CONSEQUENCE, not the detection. Content is always
|
|
31
|
+
* scanned and a detection is always audited, whatever its origin; trust decides
|
|
32
|
+
* only whether that detection may escalate the Action Guard (#233). Skipping the
|
|
33
|
+
* scan would trade away visibility, which is what got us into #225 in the first
|
|
34
|
+
* place. This keeps the operator's false-alarm cost at zero without going blind.
|
|
35
|
+
*/
|
|
36
|
+
export function classifyConversationOrigin(input) {
|
|
37
|
+
// Strict `=== true`. A missing or non-boolean flag is NOT the owner: on a host
|
|
38
|
+
// or host version that does not supply it, defaulting to "trusted" would
|
|
39
|
+
// silently disable escalation everywhere. Unknown fails toward caution.
|
|
40
|
+
const isOwner = input.senderIsOwner === true;
|
|
41
|
+
const trustOwner = input.trustOwnerInput !== false;
|
|
42
|
+
if (isOwner && trustOwner) {
|
|
43
|
+
return {
|
|
44
|
+
origin: 'owner',
|
|
45
|
+
scan: true,
|
|
46
|
+
mayTaint: false,
|
|
47
|
+
reason: 'sender is the gateway owner — their input is an instruction, not an injection vector',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (isOwner && !trustOwner) {
|
|
51
|
+
return {
|
|
52
|
+
origin: 'owner',
|
|
53
|
+
scan: true,
|
|
54
|
+
mayTaint: true,
|
|
55
|
+
reason: 'sender is the owner, but conversationTrust.trustOwnerInput is disabled on this host',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (input.senderIsOwner === false) {
|
|
59
|
+
return {
|
|
60
|
+
origin: 'non-owner',
|
|
61
|
+
scan: true,
|
|
62
|
+
mayTaint: true,
|
|
63
|
+
reason: 'sender is not the owner — treated as data, including another agent on a trusted channel',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
origin: 'unknown',
|
|
68
|
+
scan: true,
|
|
69
|
+
mayTaint: true,
|
|
70
|
+
reason: 'sender unknown (host did not supply senderIsOwner) — cannot prove owner, so treated as data',
|
|
71
|
+
};
|
|
72
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,9 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
import { readConversationAccess, describeRegisteredHooks } from './conversation-access.js';
|
|
17
|
+
import { createSessionTaintStore } from './session-taint.js';
|
|
18
|
+
import { classifyConversationOrigin } from './conversation-trust.js';
|
|
16
19
|
import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
|
|
17
20
|
import { syncInterceptEvent } from './intercept-ingest.js';
|
|
18
21
|
import { cloudSync } from './cloud-sync.js';
|
|
@@ -134,6 +137,11 @@ async function getDefenceModule() {
|
|
|
134
137
|
return _defenceModPromise;
|
|
135
138
|
}
|
|
136
139
|
// Test seams (jest only): inject a stub defence module / spy runtime, then reset.
|
|
140
|
+
/** Test seam for the conversation-taint store — lets a test assert that a
|
|
141
|
+
* detection actually reached the store (or, for owner input, did not). */
|
|
142
|
+
export function __getSessionTaintForTest() {
|
|
143
|
+
return sessionTaint;
|
|
144
|
+
}
|
|
137
145
|
export function __setDefenceModuleForTest(mod) {
|
|
138
146
|
_defenceModOverride = mod;
|
|
139
147
|
_defenceModPromise = null;
|
|
@@ -155,6 +163,13 @@ const INTERCEPT_SEVERITIES = ['low', 'medium', 'high', 'critical'];
|
|
|
155
163
|
const INTERCEPT_ACTIONS = ['log', 'warn', 'require_approval'];
|
|
156
164
|
const FAILURE_ACTIONS = ['allow', 'deny'];
|
|
157
165
|
const PLUGIN_ID = "shieldcortex-realtime";
|
|
166
|
+
/**
|
|
167
|
+
* #233: conversation-level taint, shared between the conversation scan (which
|
|
168
|
+
* writes it) and the Action Guard (which reads it). Both run in THIS process,
|
|
169
|
+
* so an in-memory store is the whole mechanism — keyed per session because one
|
|
170
|
+
* gateway serves many concurrent chats.
|
|
171
|
+
*/
|
|
172
|
+
const sessionTaint = createSessionTaintStore();
|
|
158
173
|
const PLUGIN_PACKAGE_NAME = "@drakon-systems/shieldcortex-realtime";
|
|
159
174
|
const PLUGIN_CONFIG_UI_HINTS = {
|
|
160
175
|
binaryPath: {
|
|
@@ -859,6 +874,24 @@ function isInternalContent(text) {
|
|
|
859
874
|
export async function scanLlmInput(event, _ctx) {
|
|
860
875
|
try {
|
|
861
876
|
// Only scan user content, skip system/boot/heartbeat prompts
|
|
877
|
+
// Trust is resolved per TURN, not per message: the host tells us who sent
|
|
878
|
+
// this turn, but history messages carry no individual attribution, so there
|
|
879
|
+
// is no honest way to score them separately.
|
|
880
|
+
//
|
|
881
|
+
// Resolved LAZILY, on first detection only. Computing it up front would put
|
|
882
|
+
// a config read on every single turn to answer a question that only matters
|
|
883
|
+
// when something is actually found.
|
|
884
|
+
let trustMemo = null;
|
|
885
|
+
const resolveTrust = async () => {
|
|
886
|
+
if (!trustMemo) {
|
|
887
|
+
trustMemo = classifyConversationOrigin({
|
|
888
|
+
senderIsOwner: event.senderIsOwner,
|
|
889
|
+
trustOwnerInput: (await loadConfig())
|
|
890
|
+
?.conversationTrust?.trustOwnerInput,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
return trustMemo;
|
|
894
|
+
};
|
|
862
895
|
const userTexts = extractUserContent(event.historyMessages).slice(-5);
|
|
863
896
|
const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t));
|
|
864
897
|
for (const text of texts) {
|
|
@@ -866,7 +899,22 @@ export async function scanLlmInput(event, _ctx) {
|
|
|
866
899
|
continue;
|
|
867
900
|
const result = await scanRealtimeContent(text);
|
|
868
901
|
if (!result.clean) {
|
|
869
|
-
|
|
902
|
+
const trust = await resolveTrust();
|
|
903
|
+
console.warn(`[shieldcortex] ⚠️ Threat in LLM input: ${result.summary} [${trust.origin}]`);
|
|
904
|
+
// #233: THE sink that has teeth. Logging a detection changed nothing —
|
|
905
|
+
// the tool call this injection is steering, one turn later, was
|
|
906
|
+
// evaluated as if it had never been seen. Tainting the session makes
|
|
907
|
+
// the Action Guard tighten by one notch for a bounded window.
|
|
908
|
+
//
|
|
909
|
+
// Source trust gates the CONSEQUENCE, never the detection: the warn and
|
|
910
|
+
// the audit row above happen whoever sent this. What trust decides is
|
|
911
|
+
// whether it may tighten the guard. The operator typing "delete the old
|
|
912
|
+
// logs" is an instruction, and treating it as an attack is the false
|
|
913
|
+
// alarm that gets a control switched off. Everything the agent was
|
|
914
|
+
// handed — including another agent on a closed channel — is data.
|
|
915
|
+
if (trust.mayTaint) {
|
|
916
|
+
sessionTaint.mark(event.sessionId, { reason: `conversation scan: ${result.summary}` });
|
|
917
|
+
}
|
|
870
918
|
const entry = {
|
|
871
919
|
type: "threat", hook: "llm_input", sessionId: event.sessionId,
|
|
872
920
|
model: event.model, reason: result.summary,
|
|
@@ -989,11 +1037,12 @@ function buildTypedApprovalRequest(message) {
|
|
|
989
1037
|
allowedDecisions: ["allow-once", "deny"],
|
|
990
1038
|
};
|
|
991
1039
|
}
|
|
992
|
-
async function handleTypedBeforeToolCall(event, interceptor, logger) {
|
|
1040
|
+
async function handleTypedBeforeToolCall(event, interceptor, logger, sessionId) {
|
|
993
1041
|
try {
|
|
994
1042
|
await interceptor.handleToolCall({
|
|
995
1043
|
toolName: event.toolName,
|
|
996
1044
|
arguments: event.params ?? {},
|
|
1045
|
+
sessionId,
|
|
997
1046
|
requireApproval: async (message) => {
|
|
998
1047
|
throw new TypedApprovalRequest(message, buildTypedApprovalRequest(message));
|
|
999
1048
|
},
|
|
@@ -1168,6 +1217,13 @@ export default {
|
|
|
1168
1217
|
? defenceMod.evaluateToolCall
|
|
1169
1218
|
: undefined,
|
|
1170
1219
|
broker: resolveBrokerRuntime(defenceMod, interceptorConfig.actionGuard?.broker, api),
|
|
1220
|
+
// #233: the read side of conversation taint. Returns null for a clean
|
|
1221
|
+
// or unknown session, so the guard behaves exactly as before unless a
|
|
1222
|
+
// conversation detection actually happened in THIS session.
|
|
1223
|
+
sessionTaint: (sessionId) => {
|
|
1224
|
+
const rec = sessionId ? sessionTaint.get(sessionId) : null;
|
|
1225
|
+
return rec ? { reason: rec.reason } : null;
|
|
1226
|
+
},
|
|
1171
1227
|
onAuditEntry: (entry) => syncInterceptEvent(entry, {
|
|
1172
1228
|
cloudApiKey: scConfig.cloudApiKey ?? '',
|
|
1173
1229
|
cloudBaseUrl: scConfig.cloudBaseUrl ?? 'https://api.shieldcortex.ai',
|
|
@@ -1203,17 +1259,25 @@ export default {
|
|
|
1203
1259
|
if (!interceptorDisabledInHostConfig) {
|
|
1204
1260
|
// Typed before_tool_call hook: this is the OpenClaw agent-loop gate that
|
|
1205
1261
|
// can block or require approval before the selected tool executes.
|
|
1206
|
-
api.on('before_tool_call', async (event) => {
|
|
1262
|
+
api.on('before_tool_call', async (event, ctx) => {
|
|
1207
1263
|
const interceptor = await initInterceptor();
|
|
1208
1264
|
if (!interceptor)
|
|
1209
1265
|
return;
|
|
1210
|
-
|
|
1266
|
+
// #233: the host supplies the session on the tool CONTEXT, not the
|
|
1267
|
+
// event. Without it a taint cannot be matched to the call it should
|
|
1268
|
+
// gate, so the escalation would silently never fire.
|
|
1269
|
+
return handleTypedBeforeToolCall(event, interceptor, api.logger, ctx?.sessionId);
|
|
1211
1270
|
}, { priority: 80, timeoutMs: 30_000 });
|
|
1212
1271
|
_beforeToolCallRegistered = true;
|
|
1213
1272
|
// Try to register session_end for cache cleanup (only meaningful while
|
|
1214
1273
|
// an interceptor can exist)
|
|
1215
1274
|
try {
|
|
1216
|
-
api.on('session_end', () => {
|
|
1275
|
+
api.on('session_end', (ev) => {
|
|
1276
|
+
interceptorReady?.resetSession();
|
|
1277
|
+
// #233: a taint must not outlive the conversation that earned it.
|
|
1278
|
+
if (ev?.sessionId)
|
|
1279
|
+
sessionTaint.clear(ev.sessionId);
|
|
1280
|
+
});
|
|
1217
1281
|
}
|
|
1218
1282
|
catch {
|
|
1219
1283
|
// session_end may not be a supported hook — TTL safety net handles this
|
|
@@ -1222,9 +1286,25 @@ export default {
|
|
|
1222
1286
|
else {
|
|
1223
1287
|
api.logger?.info?.('[shieldcortex] interceptor.enabled:false in plugin config — before_tool_call hook not registered');
|
|
1224
1288
|
}
|
|
1289
|
+
// These two are CONVERSATION hooks: OpenClaw drops them at registration
|
|
1290
|
+
// for a non-bundled plugin unless the host grants
|
|
1291
|
+
// plugins.entries.<id>.hooks.allowConversationAccess = true. We still
|
|
1292
|
+
// attempt registration (the host decides, and the grant can be added
|
|
1293
|
+
// without a code change), but we must not CLAIM them afterwards — see the
|
|
1294
|
+
// honesty note on the log line below (#225).
|
|
1225
1295
|
api.on("llm_input", handleLlmInput, { timeoutMs: 30_000 });
|
|
1226
1296
|
api.on("llm_output", handleLlmOutput, { timeoutMs: 30_000 });
|
|
1227
|
-
|
|
1297
|
+
// #225: this line used to announce `llm_input + llm_output` unconditionally.
|
|
1298
|
+
// On any host without the conversation-access grant the gateway logged, on
|
|
1299
|
+
// the very next two lines, that it had dropped both — so ShieldCortex was
|
|
1300
|
+
// claiming conversation protection it did not have, in the one place an
|
|
1301
|
+
// operator looks to confirm startup. Report only what is actually live, and
|
|
1302
|
+
// name the missing grant when it is the reason.
|
|
1303
|
+
const conversationAccess = readConversationAccess(homedir(), PLUGIN_ID);
|
|
1304
|
+
api.logger.info(`[shieldcortex] v${_version} registered (${describeRegisteredHooks({
|
|
1305
|
+
access: conversationAccess,
|
|
1306
|
+
beforeToolCallRegistered: _beforeToolCallRegistered,
|
|
1307
|
+
})})`);
|
|
1228
1308
|
}
|
|
1229
1309
|
catch (err) {
|
|
1230
1310
|
// Plugin must never block channel startup — warn and bail gracefully.
|
package/dist/interceptor.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdirSync, appendFileSync, readFileSync, realpathSync, statSync } from
|
|
|
3
3
|
import { join, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { createGatewayInvoker } from './broker-invoker.js';
|
|
6
|
+
import { escalateForTaint } from './session-taint.js';
|
|
6
7
|
const WATCHED_TOOLS = ['remember', 'mcp__memory__remember'];
|
|
7
8
|
const CONTENT_FIELDS = {
|
|
8
9
|
remember: ['content', 'title'],
|
|
@@ -693,6 +694,44 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
693
694
|
handleGuardUnavailable(context, `action-guard error: ${err instanceof Error ? err.message : err}`);
|
|
694
695
|
return;
|
|
695
696
|
}
|
|
697
|
+
// #233: a prompt injection detected on the CONVERSATION path earlier in this
|
|
698
|
+
// session tightens the guard by one notch for a bounded window — sensitive
|
|
699
|
+
// work starts asking, dangerous work stops. Benign work is untouched: an
|
|
700
|
+
// agent that cannot read a file is useless, and a taint response that halts
|
|
701
|
+
// ordinary work is one operators switch off.
|
|
702
|
+
//
|
|
703
|
+
// This is the enforcement answer instead of blocking the turn itself
|
|
704
|
+
// (see docs/design/2026-08-10-conversation-taint-escalation.md): the guard
|
|
705
|
+
// is already trusted, already gates actions, and a false positive here
|
|
706
|
+
// costs an approval prompt rather than the user's message.
|
|
707
|
+
//
|
|
708
|
+
// Failure is soft by construction — no taint lookup, or a throwing one,
|
|
709
|
+
// simply means no escalation. This must never become a way for a broken
|
|
710
|
+
// scanner to start denying tool calls.
|
|
711
|
+
let taint = null;
|
|
712
|
+
try {
|
|
713
|
+
taint = options?.sessionTaint?.(context.sessionId) ?? null;
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
taint = null;
|
|
717
|
+
}
|
|
718
|
+
let escalation;
|
|
719
|
+
if (taint) {
|
|
720
|
+
const esc = escalateForTaint({
|
|
721
|
+
decision: v.decision,
|
|
722
|
+
severity: v.severity,
|
|
723
|
+
tainted: true,
|
|
724
|
+
});
|
|
725
|
+
if (esc.escalated) {
|
|
726
|
+
log.warn(`[shieldcortex] action-guard ESCALATED ${context.toolName}: ${v.decision} → ${esc.decision} (tainted session: ${taint.reason})`);
|
|
727
|
+
// Recorded STRUCTURALLY, not just in the reason string: the audit entry
|
|
728
|
+
// has no reason field, so an escalation folded into the verdict text
|
|
729
|
+
// would vanish from the durable record — invisible in exactly the
|
|
730
|
+
// forensic view that needs it.
|
|
731
|
+
escalation = { by: 'session-taint', from: v.decision, to: esc.decision, reason: taint.reason };
|
|
732
|
+
v = { ...v, decision: esc.decision, reason: `${v.reason} — ESCALATED by tainted session: ${taint.reason}` };
|
|
733
|
+
}
|
|
734
|
+
}
|
|
696
735
|
if (v.decision === 'allow') {
|
|
697
736
|
// Issue #95: a RECOGNISED allow (the guard evaluated a known operation
|
|
698
737
|
// family and let it through — severity above benign) leaves an audit
|
|
@@ -706,7 +745,7 @@ export function createInterceptor(config, pipeline, options) {
|
|
|
706
745
|
return;
|
|
707
746
|
}
|
|
708
747
|
const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
|
|
709
|
-
const base = guardAuditBase(context.toolName, v, preview);
|
|
748
|
+
const base = { ...guardAuditBase(context.toolName, v, preview), ...(escalation ? { escalated: escalation } : {}) };
|
|
710
749
|
const severity = v.severity === 'catastrophic' ? 'critical' : 'high';
|
|
711
750
|
// Catastrophic / exfil — hard block, always enforced when the guard is enabled.
|
|
712
751
|
if (v.decision === 'block') {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.47.
|
|
3
|
+
"version": "4.47.37",
|
|
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,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session taint → Action Guard escalation (issue #233).
|
|
3
|
+
*
|
|
4
|
+
* The hole this closes: the conversation-scan path and the Action Guard share
|
|
5
|
+
* NO state today. `scanLlmInput` detects a prompt injection, writes a log line
|
|
6
|
+
* and a JSONL row that nothing reads back, and the tool call that injection is
|
|
7
|
+
* steering — one turn later — is evaluated as if nothing had happened.
|
|
8
|
+
*
|
|
9
|
+
* The fix is deliberately NOT to block the turn. `before_agent_run` is
|
|
10
|
+
* fail-closed with a 15s budget the host owns, so every ShieldCortex fault
|
|
11
|
+
* (throw, timeout, a stray `null`) becomes a dead user turn, and a false
|
|
12
|
+
* positive destroys the user's message irrecoverably. See
|
|
13
|
+
* docs/design/2026-08-10-conversation-taint-escalation.md.
|
|
14
|
+
*
|
|
15
|
+
* Instead: a detection TAINTS the session, and the Action Guard — which
|
|
16
|
+
* already gates actions, is already trusted, and is not a conversation hook —
|
|
17
|
+
* reads that taint and tightens by one notch for a bounded window. The agent
|
|
18
|
+
* keeps thinking; it just needs a human before it does anything consequential.
|
|
19
|
+
*
|
|
20
|
+
* Both the conversation scan and the interceptor run in the SAME gateway
|
|
21
|
+
* process, so this store is in-memory by design. It is keyed per session
|
|
22
|
+
* because one gateway serves many concurrent chats — a process-global flag
|
|
23
|
+
* would leak one conversation's taint onto every other conversation's tools.
|
|
24
|
+
*/
|
|
25
|
+
/** How long a single detection keeps a session tainted. */
|
|
26
|
+
export const TAINT_TTL_MS = 15 * 60_000;
|
|
27
|
+
/** Hard cap on tracked sessions — a long-lived gateway must not grow forever. */
|
|
28
|
+
export const MAX_TAINTED_SESSIONS = 500;
|
|
29
|
+
export function createSessionTaintStore() {
|
|
30
|
+
const records = new Map();
|
|
31
|
+
function prune(nowMs) {
|
|
32
|
+
for (const [id, rec] of records) {
|
|
33
|
+
if (rec.expiresAtMs <= nowMs)
|
|
34
|
+
records.delete(id);
|
|
35
|
+
}
|
|
36
|
+
// Still over cap after expiry (many live sessions): drop the oldest marks.
|
|
37
|
+
// Evicting the OLDEST is the safe direction — the most recent detections
|
|
38
|
+
// are the ones whose tool calls have not happened yet.
|
|
39
|
+
if (records.size > MAX_TAINTED_SESSIONS) {
|
|
40
|
+
const bySeniority = [...records.entries()].sort((a, b) => a[1].markedAtMs - b[1].markedAtMs);
|
|
41
|
+
for (const [id] of bySeniority.slice(0, records.size - MAX_TAINTED_SESSIONS))
|
|
42
|
+
records.delete(id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
mark(sessionId, input) {
|
|
47
|
+
if (!sessionId)
|
|
48
|
+
return;
|
|
49
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
50
|
+
const ttl = input.ttlMs ?? TAINT_TTL_MS;
|
|
51
|
+
const severity = input.severity ?? 'unknown';
|
|
52
|
+
const existing = records.get(sessionId);
|
|
53
|
+
// Re-marking EXTENDS the window and keeps the higher severity — a second
|
|
54
|
+
// injection in the same session must never shorten the hold or downgrade
|
|
55
|
+
// it to the newer, milder finding.
|
|
56
|
+
const next = {
|
|
57
|
+
sessionId,
|
|
58
|
+
severity: existing && rank(existing.severity) > rank(severity) ? existing.severity : severity,
|
|
59
|
+
reason: input.reason,
|
|
60
|
+
markedAtMs: nowMs,
|
|
61
|
+
expiresAtMs: Math.max(nowMs + ttl, existing?.expiresAtMs ?? 0),
|
|
62
|
+
};
|
|
63
|
+
records.set(sessionId, next);
|
|
64
|
+
prune(nowMs);
|
|
65
|
+
},
|
|
66
|
+
get(sessionId, nowMs = Date.now()) {
|
|
67
|
+
if (!sessionId)
|
|
68
|
+
return null;
|
|
69
|
+
const rec = records.get(sessionId);
|
|
70
|
+
if (!rec)
|
|
71
|
+
return null;
|
|
72
|
+
if (rec.expiresAtMs <= nowMs) {
|
|
73
|
+
records.delete(sessionId);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return rec;
|
|
77
|
+
},
|
|
78
|
+
clear(sessionId) {
|
|
79
|
+
records.delete(sessionId);
|
|
80
|
+
},
|
|
81
|
+
reset() {
|
|
82
|
+
records.clear();
|
|
83
|
+
},
|
|
84
|
+
size() {
|
|
85
|
+
return records.size;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function rank(s) {
|
|
90
|
+
switch (s) {
|
|
91
|
+
case 'critical': return 4;
|
|
92
|
+
case 'high': return 3;
|
|
93
|
+
case 'medium': return 2;
|
|
94
|
+
case 'low': return 1;
|
|
95
|
+
default: return 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* One notch tighter while tainted. Benign work is untouched on purpose: an
|
|
100
|
+
* agent that cannot read a file is useless, and a taint response that stops
|
|
101
|
+
* ordinary work is one operators will switch off.
|
|
102
|
+
*
|
|
103
|
+
* benign → unchanged (allow)
|
|
104
|
+
* sensitive → require approval (was allow)
|
|
105
|
+
* dangerous → block (was require approval)
|
|
106
|
+
* catastrophic → unchanged (already blocked)
|
|
107
|
+
*
|
|
108
|
+
* Escalation NEVER downgrades: if the guard already decided something stricter
|
|
109
|
+
* than the escalated value, the guard wins.
|
|
110
|
+
*/
|
|
111
|
+
export function escalateForTaint(input) {
|
|
112
|
+
const { decision, severity, tainted } = input;
|
|
113
|
+
if (!tainted)
|
|
114
|
+
return { decision, escalated: false };
|
|
115
|
+
let target = decision;
|
|
116
|
+
if (severity === 'sensitive')
|
|
117
|
+
target = 'require_approval';
|
|
118
|
+
else if (severity === 'dangerous')
|
|
119
|
+
target = 'block';
|
|
120
|
+
// Only ever move toward caution.
|
|
121
|
+
const next = strength(target) > strength(decision) ? target : decision;
|
|
122
|
+
return { decision: next, escalated: next !== decision };
|
|
123
|
+
}
|
|
124
|
+
function strength(d) {
|
|
125
|
+
switch (d) {
|
|
126
|
+
case 'block': return 3;
|
|
127
|
+
case 'require_approval': return 2;
|
|
128
|
+
default: return 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/index.ts
CHANGED
|
@@ -15,6 +15,9 @@ import path from "node:path";
|
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
17
|
|
|
18
|
+
import { readConversationAccess, describeRegisteredHooks } from './conversation-access.js';
|
|
19
|
+
import { createSessionTaintStore } from './session-taint.js';
|
|
20
|
+
import { classifyConversationOrigin } from './conversation-trust.js';
|
|
18
21
|
import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
|
|
19
22
|
import type { InterceptorConfig, BrokerRuntime } from './interceptor.js';
|
|
20
23
|
import { syncInterceptEvent } from './intercept-ingest.js';
|
|
@@ -170,6 +173,12 @@ async function getDefenceModule(): Promise<DefenceModule | null> {
|
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
// Test seams (jest only): inject a stub defence module / spy runtime, then reset.
|
|
176
|
+
/** Test seam for the conversation-taint store — lets a test assert that a
|
|
177
|
+
* detection actually reached the store (or, for owner input, did not). */
|
|
178
|
+
export function __getSessionTaintForTest(): typeof sessionTaint {
|
|
179
|
+
return sessionTaint;
|
|
180
|
+
}
|
|
181
|
+
|
|
173
182
|
export function __setDefenceModuleForTest(mod: DefenceModule | null | undefined): void {
|
|
174
183
|
_defenceModOverride = mod;
|
|
175
184
|
_defenceModPromise = null;
|
|
@@ -190,6 +199,9 @@ export function __resetConfigStateForTest(): void {
|
|
|
190
199
|
type LlmInputEvent = {
|
|
191
200
|
runId: string; sessionId: string; provider: string; model: string;
|
|
192
201
|
systemPrompt?: string; prompt: string; historyMessages: unknown[]; imagesCount: number;
|
|
202
|
+
/** Host-supplied: this turn came from the gateway's owner. Absent on hosts
|
|
203
|
+
* that do not report it — treated as NOT the owner, never as trusted. */
|
|
204
|
+
senderIsOwner?: boolean;
|
|
193
205
|
};
|
|
194
206
|
type LlmOutputEvent = {
|
|
195
207
|
runId: string; sessionId: string; provider: string; model: string;
|
|
@@ -267,6 +279,14 @@ interface SCConfig {
|
|
|
267
279
|
}
|
|
268
280
|
|
|
269
281
|
const PLUGIN_ID = "shieldcortex-realtime";
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* #233: conversation-level taint, shared between the conversation scan (which
|
|
285
|
+
* writes it) and the Action Guard (which reads it). Both run in THIS process,
|
|
286
|
+
* so an in-memory store is the whole mechanism — keyed per session because one
|
|
287
|
+
* gateway serves many concurrent chats.
|
|
288
|
+
*/
|
|
289
|
+
const sessionTaint = createSessionTaintStore();
|
|
270
290
|
const PLUGIN_PACKAGE_NAME = "@drakon-systems/shieldcortex-realtime";
|
|
271
291
|
const PLUGIN_CONFIG_UI_HINTS = {
|
|
272
292
|
binaryPath: {
|
|
@@ -1033,13 +1053,46 @@ function isInternalContent(text: string): boolean {
|
|
|
1033
1053
|
export async function scanLlmInput(event: LlmInputEvent, _ctx: AgentCtx): Promise<void> {
|
|
1034
1054
|
try {
|
|
1035
1055
|
// Only scan user content, skip system/boot/heartbeat prompts
|
|
1056
|
+
// Trust is resolved per TURN, not per message: the host tells us who sent
|
|
1057
|
+
// this turn, but history messages carry no individual attribution, so there
|
|
1058
|
+
// is no honest way to score them separately.
|
|
1059
|
+
//
|
|
1060
|
+
// Resolved LAZILY, on first detection only. Computing it up front would put
|
|
1061
|
+
// a config read on every single turn to answer a question that only matters
|
|
1062
|
+
// when something is actually found.
|
|
1063
|
+
let trustMemo: ReturnType<typeof classifyConversationOrigin> | null = null;
|
|
1064
|
+
const resolveTrust = async () => {
|
|
1065
|
+
if (!trustMemo) {
|
|
1066
|
+
trustMemo = classifyConversationOrigin({
|
|
1067
|
+
senderIsOwner: event.senderIsOwner,
|
|
1068
|
+
trustOwnerInput: (await loadConfig() as { conversationTrust?: { trustOwnerInput?: boolean } })
|
|
1069
|
+
?.conversationTrust?.trustOwnerInput,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
return trustMemo;
|
|
1073
|
+
};
|
|
1036
1074
|
const userTexts = extractUserContent(event.historyMessages).slice(-5);
|
|
1037
1075
|
const texts = [event.prompt, ...userTexts].filter(t => t && !isInternalContent(t));
|
|
1038
1076
|
for (const text of texts) {
|
|
1039
1077
|
if (!text || text.length < 10) continue;
|
|
1040
1078
|
const result = await scanRealtimeContent(text);
|
|
1041
1079
|
if (!result.clean) {
|
|
1042
|
-
|
|
1080
|
+
const trust = await resolveTrust();
|
|
1081
|
+
console.warn(`[shieldcortex] ⚠️ Threat in LLM input: ${result.summary} [${trust.origin}]`);
|
|
1082
|
+
// #233: THE sink that has teeth. Logging a detection changed nothing —
|
|
1083
|
+
// the tool call this injection is steering, one turn later, was
|
|
1084
|
+
// evaluated as if it had never been seen. Tainting the session makes
|
|
1085
|
+
// the Action Guard tighten by one notch for a bounded window.
|
|
1086
|
+
//
|
|
1087
|
+
// Source trust gates the CONSEQUENCE, never the detection: the warn and
|
|
1088
|
+
// the audit row above happen whoever sent this. What trust decides is
|
|
1089
|
+
// whether it may tighten the guard. The operator typing "delete the old
|
|
1090
|
+
// logs" is an instruction, and treating it as an attack is the false
|
|
1091
|
+
// alarm that gets a control switched off. Everything the agent was
|
|
1092
|
+
// handed — including another agent on a closed channel — is data.
|
|
1093
|
+
if (trust.mayTaint) {
|
|
1094
|
+
sessionTaint.mark(event.sessionId, { reason: `conversation scan: ${result.summary}` });
|
|
1095
|
+
}
|
|
1043
1096
|
const entry = {
|
|
1044
1097
|
type: "threat", hook: "llm_input", sessionId: event.sessionId,
|
|
1045
1098
|
model: event.model, reason: result.summary,
|
|
@@ -1168,11 +1221,13 @@ async function handleTypedBeforeToolCall(
|
|
|
1168
1221
|
event: TypedBeforeToolCallEvent,
|
|
1169
1222
|
interceptor: ReturnType<typeof createInterceptor>,
|
|
1170
1223
|
logger: PluginApi["logger"],
|
|
1224
|
+
sessionId?: string,
|
|
1171
1225
|
): Promise<TypedBeforeToolCallResult | void> {
|
|
1172
1226
|
try {
|
|
1173
1227
|
await interceptor.handleToolCall({
|
|
1174
1228
|
toolName: event.toolName,
|
|
1175
1229
|
arguments: event.params ?? {},
|
|
1230
|
+
sessionId,
|
|
1176
1231
|
requireApproval: async (message: string) => {
|
|
1177
1232
|
throw new TypedApprovalRequest(message, buildTypedApprovalRequest(message));
|
|
1178
1233
|
},
|
|
@@ -1364,6 +1419,13 @@ export default {
|
|
|
1364
1419
|
? ((defenceMod as any).evaluateToolCall as Parameters<typeof createInterceptor>[2] extends { evaluateToolCall?: infer E } ? E : never)
|
|
1365
1420
|
: undefined,
|
|
1366
1421
|
broker: resolveBrokerRuntime(defenceMod, interceptorConfig.actionGuard?.broker, api),
|
|
1422
|
+
// #233: the read side of conversation taint. Returns null for a clean
|
|
1423
|
+
// or unknown session, so the guard behaves exactly as before unless a
|
|
1424
|
+
// conversation detection actually happened in THIS session.
|
|
1425
|
+
sessionTaint: (sessionId) => {
|
|
1426
|
+
const rec = sessionId ? sessionTaint.get(sessionId) : null;
|
|
1427
|
+
return rec ? { reason: rec.reason } : null;
|
|
1428
|
+
},
|
|
1367
1429
|
onAuditEntry: (entry) => syncInterceptEvent(entry, {
|
|
1368
1430
|
cloudApiKey: (scConfig as any).cloudApiKey ?? '',
|
|
1369
1431
|
cloudBaseUrl: (scConfig as any).cloudBaseUrl ?? 'https://api.shieldcortex.ai',
|
|
@@ -1400,17 +1462,24 @@ export default {
|
|
|
1400
1462
|
if (!interceptorDisabledInHostConfig) {
|
|
1401
1463
|
// Typed before_tool_call hook: this is the OpenClaw agent-loop gate that
|
|
1402
1464
|
// can block or require approval before the selected tool executes.
|
|
1403
|
-
api.on('before_tool_call', async (event: TypedBeforeToolCallEvent) => {
|
|
1465
|
+
api.on('before_tool_call', async (event: TypedBeforeToolCallEvent, ctx?: { sessionId?: string }) => {
|
|
1404
1466
|
const interceptor = await initInterceptor();
|
|
1405
1467
|
if (!interceptor) return;
|
|
1406
|
-
|
|
1468
|
+
// #233: the host supplies the session on the tool CONTEXT, not the
|
|
1469
|
+
// event. Without it a taint cannot be matched to the call it should
|
|
1470
|
+
// gate, so the escalation would silently never fire.
|
|
1471
|
+
return handleTypedBeforeToolCall(event, interceptor, api.logger, ctx?.sessionId);
|
|
1407
1472
|
}, { priority: 80, timeoutMs: 30_000 });
|
|
1408
1473
|
_beforeToolCallRegistered = true;
|
|
1409
1474
|
|
|
1410
1475
|
// Try to register session_end for cache cleanup (only meaningful while
|
|
1411
1476
|
// an interceptor can exist)
|
|
1412
1477
|
try {
|
|
1413
|
-
api.on('session_end', (
|
|
1478
|
+
api.on('session_end', (ev?: { sessionId?: string }) => {
|
|
1479
|
+
interceptorReady?.resetSession();
|
|
1480
|
+
// #233: a taint must not outlive the conversation that earned it.
|
|
1481
|
+
if (ev?.sessionId) sessionTaint.clear(ev.sessionId);
|
|
1482
|
+
});
|
|
1414
1483
|
} catch {
|
|
1415
1484
|
// session_end may not be a supported hook — TTL safety net handles this
|
|
1416
1485
|
}
|
|
@@ -1418,10 +1487,28 @@ export default {
|
|
|
1418
1487
|
api.logger?.info?.('[shieldcortex] interceptor.enabled:false in plugin config — before_tool_call hook not registered');
|
|
1419
1488
|
}
|
|
1420
1489
|
|
|
1490
|
+
// These two are CONVERSATION hooks: OpenClaw drops them at registration
|
|
1491
|
+
// for a non-bundled plugin unless the host grants
|
|
1492
|
+
// plugins.entries.<id>.hooks.allowConversationAccess = true. We still
|
|
1493
|
+
// attempt registration (the host decides, and the grant can be added
|
|
1494
|
+
// without a code change), but we must not CLAIM them afterwards — see the
|
|
1495
|
+
// honesty note on the log line below (#225).
|
|
1421
1496
|
api.on("llm_input", handleLlmInput, { timeoutMs: 30_000 });
|
|
1422
1497
|
api.on("llm_output", handleLlmOutput, { timeoutMs: 30_000 });
|
|
1423
1498
|
|
|
1424
|
-
|
|
1499
|
+
// #225: this line used to announce `llm_input + llm_output` unconditionally.
|
|
1500
|
+
// On any host without the conversation-access grant the gateway logged, on
|
|
1501
|
+
// the very next two lines, that it had dropped both — so ShieldCortex was
|
|
1502
|
+
// claiming conversation protection it did not have, in the one place an
|
|
1503
|
+
// operator looks to confirm startup. Report only what is actually live, and
|
|
1504
|
+
// name the missing grant when it is the reason.
|
|
1505
|
+
const conversationAccess = readConversationAccess(homedir(), PLUGIN_ID);
|
|
1506
|
+
api.logger.info(
|
|
1507
|
+
`[shieldcortex] v${_version} registered (${describeRegisteredHooks({
|
|
1508
|
+
access: conversationAccess,
|
|
1509
|
+
beforeToolCallRegistered: _beforeToolCallRegistered,
|
|
1510
|
+
})})`,
|
|
1511
|
+
);
|
|
1425
1512
|
} catch (err) {
|
|
1426
1513
|
// Plugin must never block channel startup — warn and bail gracefully.
|
|
1427
1514
|
// #134 §2: this used to be a bare console.warn, which bypasses the
|
package/interceptor.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdirSync, appendFileSync, readFileSync, realpathSync, statSync } from
|
|
|
3
3
|
import { join, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { createGatewayInvoker, type BrokerInvokerContext, type ModelInvokerLike } from './broker-invoker.js';
|
|
6
|
+
import { escalateForTaint, type GuardDecision, type GuardSeverity } from './session-taint.js';
|
|
6
7
|
|
|
7
8
|
export type Severity = 'low' | 'medium' | 'high' | 'critical';
|
|
8
9
|
export type InterceptAction = 'log' | 'warn' | 'require_approval';
|
|
@@ -166,6 +167,9 @@ export interface ToolCallContext {
|
|
|
166
167
|
* used to resolve a relative script path (issue #4). Falls back to the
|
|
167
168
|
* call's own `cwd` argument, then `process.cwd()`. */
|
|
168
169
|
cwd?: string;
|
|
170
|
+
/** The gateway session this call belongs to (#233). Used to look up a
|
|
171
|
+
* conversation-level taint; absent means no escalation, never a default. */
|
|
172
|
+
sessionId?: string;
|
|
169
173
|
}
|
|
170
174
|
|
|
171
175
|
export interface InterceptAuditEntry {
|
|
@@ -191,6 +195,11 @@ export interface InterceptAuditEntry {
|
|
|
191
195
|
* no pattern produced a span. `secret-egress` never contributes one — the
|
|
192
196
|
* span would be the secret. */
|
|
193
197
|
matches?: Array<{ signal: string; span: string }>;
|
|
198
|
+
/** Set when a conversation-level detection earlier in this session tightened
|
|
199
|
+
* the verdict (#233). Present ONLY when taint actually changed the answer,
|
|
200
|
+
* so an escalated denial is tellable from a natively catastrophic one — and
|
|
201
|
+
* a reviewer can find every decision the conversation scanner influenced. */
|
|
202
|
+
escalated?: { by: 'session-taint'; from: string; to: string; reason: string };
|
|
194
203
|
/** Files the reviewed-script allowlist exempted from folding (#189). */
|
|
195
204
|
reviewedScripts?: string[];
|
|
196
205
|
}
|
|
@@ -749,6 +758,10 @@ interface InterceptorOptions {
|
|
|
749
758
|
onAuditEntry?: (entry: InterceptAuditEntry) => void;
|
|
750
759
|
/** Tool Action Guard evaluator, injected from `shieldcortex/defence` at runtime. */
|
|
751
760
|
evaluateToolCall?: ToolGuardEvaluator;
|
|
761
|
+
/** #233: look up a conversation-level taint for this session. Returning null
|
|
762
|
+
* (or throwing) means no escalation — a broken scanner must never become a
|
|
763
|
+
* new source of denials. */
|
|
764
|
+
sessionTaint?: (sessionId: string | undefined) => { reason: string } | null;
|
|
752
765
|
/** Approval broker (#143), injected from `shieldcortex/defence` at runtime.
|
|
753
766
|
* Absent, or present with `config.enabled: false`, means no model is ever
|
|
754
767
|
* consulted and the guard behaves exactly as it did before #143. */
|
|
@@ -984,6 +997,47 @@ export function createInterceptor(
|
|
|
984
997
|
handleGuardUnavailable(context, `action-guard error: ${err instanceof Error ? err.message : err}`);
|
|
985
998
|
return;
|
|
986
999
|
}
|
|
1000
|
+
|
|
1001
|
+
// #233: a prompt injection detected on the CONVERSATION path earlier in this
|
|
1002
|
+
// session tightens the guard by one notch for a bounded window — sensitive
|
|
1003
|
+
// work starts asking, dangerous work stops. Benign work is untouched: an
|
|
1004
|
+
// agent that cannot read a file is useless, and a taint response that halts
|
|
1005
|
+
// ordinary work is one operators switch off.
|
|
1006
|
+
//
|
|
1007
|
+
// This is the enforcement answer instead of blocking the turn itself
|
|
1008
|
+
// (see docs/design/2026-08-10-conversation-taint-escalation.md): the guard
|
|
1009
|
+
// is already trusted, already gates actions, and a false positive here
|
|
1010
|
+
// costs an approval prompt rather than the user's message.
|
|
1011
|
+
//
|
|
1012
|
+
// Failure is soft by construction — no taint lookup, or a throwing one,
|
|
1013
|
+
// simply means no escalation. This must never become a way for a broken
|
|
1014
|
+
// scanner to start denying tool calls.
|
|
1015
|
+
let taint: { reason: string } | null = null;
|
|
1016
|
+
try {
|
|
1017
|
+
taint = options?.sessionTaint?.(context.sessionId) ?? null;
|
|
1018
|
+
} catch {
|
|
1019
|
+
taint = null;
|
|
1020
|
+
}
|
|
1021
|
+
let escalation: InterceptAuditEntry['escalated'] | undefined;
|
|
1022
|
+
if (taint) {
|
|
1023
|
+
const esc = escalateForTaint({
|
|
1024
|
+
decision: v.decision as GuardDecision,
|
|
1025
|
+
severity: v.severity as GuardSeverity,
|
|
1026
|
+
tainted: true,
|
|
1027
|
+
});
|
|
1028
|
+
if (esc.escalated) {
|
|
1029
|
+
log.warn(
|
|
1030
|
+
`[shieldcortex] action-guard ESCALATED ${context.toolName}: ${v.decision} → ${esc.decision} (tainted session: ${taint.reason})`,
|
|
1031
|
+
);
|
|
1032
|
+
// Recorded STRUCTURALLY, not just in the reason string: the audit entry
|
|
1033
|
+
// has no reason field, so an escalation folded into the verdict text
|
|
1034
|
+
// would vanish from the durable record — invisible in exactly the
|
|
1035
|
+
// forensic view that needs it.
|
|
1036
|
+
escalation = { by: 'session-taint', from: v.decision, to: esc.decision, reason: taint.reason };
|
|
1037
|
+
v = { ...v, decision: esc.decision, reason: `${v.reason} — ESCALATED by tainted session: ${taint.reason}` };
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
987
1041
|
if (v.decision === 'allow') {
|
|
988
1042
|
// Issue #95: a RECOGNISED allow (the guard evaluated a known operation
|
|
989
1043
|
// family and let it through — severity above benign) leaves an audit
|
|
@@ -998,7 +1052,7 @@ export function createInterceptor(
|
|
|
998
1052
|
}
|
|
999
1053
|
|
|
1000
1054
|
const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
|
|
1001
|
-
const base = guardAuditBase(context.toolName, v, preview);
|
|
1055
|
+
const base = { ...guardAuditBase(context.toolName, v, preview), ...(escalation ? { escalated: escalation } : {}) };
|
|
1002
1056
|
const severity: Severity = v.severity === 'catastrophic' ? 'critical' : 'high';
|
|
1003
1057
|
|
|
1004
1058
|
// Catastrophic / exfil — hard block, always enforced when the guard is enabled.
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.47.
|
|
3
|
+
"version": "4.47.37",
|
|
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,
|
package/package.json
CHANGED