@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.
@@ -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
- console.warn(`[shieldcortex] ⚠️ Threat in LLM input: ${result.summary}`);
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
- return handleTypedBeforeToolCall(event, interceptor, api.logger);
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', () => { interceptorReady?.resetSession(); });
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
- api.logger.info(`[shieldcortex] v${_version} registered (llm_input + llm_output${_beforeToolCallRegistered ? " + before_tool_call" : ""} + /shieldcortex-status)`);
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.
@@ -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.35",
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
- console.warn(`[shieldcortex] ⚠️ Threat in LLM input: ${result.summary}`);
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
- return handleTypedBeforeToolCall(event, interceptor, api.logger);
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', () => { interceptorReady?.resetSession(); });
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
- api.logger.info(`[shieldcortex] v${_version} registered (llm_input + llm_output${_beforeToolCallRegistered ? " + before_tool_call" : ""} + /shieldcortex-status)`);
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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.35",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.47.35",
3
+ "version": "4.47.37",
4
4
  "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",