@drakon-systems/shieldcortex-realtime 4.47.38 → 4.47.40

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/index.js CHANGED
@@ -2,23 +2,41 @@
2
2
  * ShieldCortex Real-time Scanning Plugin for OpenClaw v2026.3.22+
3
3
  *
4
4
  * Uses typed OpenClaw plugin hooks (`api.on`) for llm_input/llm_output
5
- * scanning and before_tool_call interception. `api.registerHook` registers
6
- * internal HOOK-style automation and does not participate in the agent-loop
7
- * block/approval semantics ShieldCortex needs.
8
- * All scanning operations are fire-and-forget.
5
+ * scanning and before_tool_call / before_agent_run interception.
6
+ * `api.registerHook` registers internal HOOK-style automation and does not
7
+ * participate in the agent-loop block/approval semantics ShieldCortex needs.
8
+ *
9
+ * NOT all scanning is fire-and-forget, and the distinction is the product:
10
+ *
11
+ * llm_input — OBSERVATION. Fire-and-forget; it has no blocking
12
+ * contract, so a detection here cannot stop the turn.
13
+ * before_agent_run — THE GATE (#225). Awaited by the gateway; its return
14
+ * value decides whether the run proceeds. Bounded end to
15
+ * end (CONVERSATION_SCAN_MAX_MS for the scan,
16
+ * CONVERSATION_NOTIFY_MAX_MS for the alert) because the
17
+ * user's turn waits on it, and failing OPEN on every
18
+ * internal error — as an EXPLICIT `{ outcome: 'pass' }`
19
+ * (#226), never as void, and never by throwing: the host
20
+ * registers this hook fail-CLOSED. See `gatePass`.
21
+ * before_tool_call — the Action Guard's gate, likewise awaited.
22
+ *
23
+ * Both conversation hooks honour `interceptor.conversation.posture`, including
24
+ * `off`, which is read before any scanner, audit write or cloud call.
9
25
  */
10
- import { createHash } from "node:crypto";
26
+ import { createHash, randomUUID } from "node:crypto";
11
27
  import fs from "node:fs/promises";
12
- import { existsSync, readFileSync, realpathSync } from "node:fs";
28
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
13
29
  import path from "node:path";
14
- import { homedir } from "node:os";
30
+ import { homedir, hostname } from "node:os";
15
31
  import { fileURLToPath, pathToFileURL } from "node:url";
32
+ import { createRequire } from "node:module";
16
33
  import { readConversationAccess, describeRegisteredHooks } from './conversation-access.js';
17
34
  import { createSessionTaintStore } from './session-taint.js';
18
35
  import { classifyConversationOrigin } from './conversation-trust.js';
19
36
  import { createInterceptor, DEFAULT_CONFIG as DEFAULT_INTERCEPTOR_CONFIG } from './interceptor.js';
20
37
  import { syncInterceptEvent } from './intercept-ingest.js';
21
38
  import { cloudSync } from './cloud-sync.js';
39
+ import { createGatewayNotifyChannel } from './gateway-notify-channel.js';
22
40
  let runtimePromise = null;
23
41
  function addRuntimeCandidate(candidates, packageRoot) {
24
42
  const runtimePath = path.join(packageRoot, "hooks", "openclaw", "cortex-memory", "runtime.mjs");
@@ -35,13 +53,66 @@ function addAncestorCandidates(candidates, startPath) {
35
53
  current = path.dirname(current);
36
54
  }
37
55
  }
38
- function collectRuntimeCandidates() {
56
+ /**
57
+ * Ask Node where the `shieldcortex` package actually is (#174).
58
+ *
59
+ * The plugin declares `shieldcortex` as a peer, so on ANY layout Node's own
60
+ * resolver can find it from here — no guessing at install prefixes. Resolving
61
+ * `shieldcortex/package.json` rather than the runtime file directly is
62
+ * deliberate: `./package.json` is the one subpath the main package's `exports`
63
+ * map always declares, whereas `hooks/openclaw/**` is in `files` but NOT in
64
+ * `exports`, so resolving it throws ERR_PACKAGE_PATH_NOT_EXPORTED.
65
+ *
66
+ * This is the strategy that fixes the reported `~/.local` host, and it works
67
+ * without widening the public `exports` surface.
68
+ */
69
+ function addResolvedPeerCandidate(candidates, fromUrl, resolve = (spec, from) => createRequire(from).resolve(spec)) {
70
+ try {
71
+ addRuntimeCandidate(candidates, path.dirname(resolve("shieldcortex/package.json", fromUrl)));
72
+ }
73
+ catch { /* not resolvable from here — later strategies still apply */ }
74
+ }
75
+ /**
76
+ * Every place the runtime might live, in the order we should try them.
77
+ *
78
+ * `home` is injected because Jest sandboxes SHIELDCORTEX_CONFIG_DIR but never
79
+ * HOME, so a test that did not inject it would probe the developer's real
80
+ * install and pass for the wrong reason.
81
+ */
82
+ function collectRuntimeCandidates(home = homedir(), resolveFrom = import.meta.url) {
39
83
  const candidates = new Set();
40
- // 1. Relative path (works when running from within npm package tree)
41
- candidates.add(new URL("../../hooks/openclaw/cortex-memory/runtime.mjs", import.meta.url).href);
42
- // 2. Config file override (reads path from ~/.shieldcortex/config.json instead of env var)
84
+ // 0. Operator escape hatch. `resolveOpenClawBinary` and the approval channel
85
+ // already honour an env override for the same class of "we guessed your
86
+ // install prefix wrong" problem; this list was the only copy without one,
87
+ // which is why every new prefix (bun, volta, asdf, ~/.local) has needed a
88
+ // code change. Accepts either the runtime file itself or a package root.
89
+ const envOverride = process.env.SHIELDCORTEX_RUNTIME_PATH?.trim();
90
+ if (envOverride) {
91
+ if (envOverride.endsWith(".mjs") && existsSync(envOverride)) {
92
+ candidates.add(pathToFileURL(envOverride).href);
93
+ }
94
+ else {
95
+ addRuntimeCandidate(candidates, envOverride);
96
+ }
97
+ }
98
+ // 1. Ask Node. Works on every layout including the ~/.local one this fixes.
99
+ addResolvedPeerCandidate(candidates, resolveFrom);
100
+ // 2. Relative path — the repo/source-tree layout, where ../../hooks/… is real.
101
+ // GUARDED, unlike before: on an installed layout this resolves to a
102
+ // non-existent scoped path (`@drakon-systems/hooks/…`), and because it was
103
+ // the only unguarded entry it became the SOLE list member and its
104
+ // ERR_MODULE_NOT_FOUND became the operator-visible failure — the exact
105
+ // message #174 reports. It is a real candidate in the source tree, so it
106
+ // is kept and screened rather than deleted.
107
+ const relative = fileURLToPath(new URL("../../hooks/openclaw/cortex-memory/runtime.mjs", resolveFrom));
108
+ if (existsSync(relative))
109
+ candidates.add(pathToFileURL(relative).href);
110
+ // 3. Config file override. Honours SHIELDCORTEX_CONFIG_DIR like the rest of
111
+ // the product — reading homedir() directly made this permanently blind on
112
+ // a host that relocates its config.
43
113
  try {
44
- const cfgPath = path.join(homedir(), ".shieldcortex", "config.json");
114
+ const configDir = process.env.SHIELDCORTEX_CONFIG_DIR?.trim() || path.join(home, ".shieldcortex");
115
+ const cfgPath = path.join(configDir, "config.json");
45
116
  if (existsSync(cfgPath)) {
46
117
  const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
47
118
  if (cfg.installRoot)
@@ -49,10 +120,15 @@ function collectRuntimeCandidates() {
49
120
  }
50
121
  }
51
122
  catch { /* no config */ }
52
- // 3. Walk up from current file location
53
- addAncestorCandidates(candidates, path.dirname(fileURLToPath(import.meta.url)));
54
- // 4. Resolve via common bin symlink paths (no child_process needed)
55
- for (const binDir of ["/usr/local/bin", "/opt/homebrew/bin", path.join(homedir(), ".npm-global", "bin")]) {
123
+ // 4. Walk up from current file location
124
+ addAncestorCandidates(candidates, path.dirname(fileURLToPath(resolveFrom)));
125
+ // 5. Resolve via common bin symlink paths (no child_process needed)
126
+ for (const binDir of [
127
+ "/usr/local/bin",
128
+ "/opt/homebrew/bin",
129
+ path.join(home, ".npm-global", "bin"),
130
+ path.join(home, ".local", "bin"), // #174: pip-style / npm --prefix ~/.local
131
+ ]) {
56
132
  const binPath = path.join(binDir, "shieldcortex");
57
133
  try {
58
134
  if (existsSync(binPath))
@@ -60,18 +136,19 @@ function collectRuntimeCandidates() {
60
136
  }
61
137
  catch { /* broken symlink */ }
62
138
  }
63
- // 5. Common global install paths (covers npm root -g results without spawning npm)
139
+ // 6. Common global install paths (covers npm root -g results without spawning npm)
64
140
  for (const root of [
65
141
  "/usr/lib/node_modules/shieldcortex",
66
142
  "/usr/local/lib/node_modules/shieldcortex",
67
143
  "/opt/homebrew/lib/node_modules/shieldcortex",
68
- path.join(homedir(), ".npm-global", "lib", "node_modules", "shieldcortex"),
69
- path.join(homedir(), ".nvm", "versions", "node"), // nvm users
144
+ path.join(home, ".npm-global", "lib", "node_modules", "shieldcortex"),
145
+ path.join(home, ".local", "lib", "node_modules", "shieldcortex"), // #174
146
+ path.join(home, ".nvm", "versions", "node"), // nvm users
70
147
  ]) {
71
148
  if (root.includes(".nvm")) {
72
149
  // For nvm, check the current symlink
73
150
  try {
74
- const currentNode = path.join(homedir(), ".nvm", "current", "lib", "node_modules", "shieldcortex");
151
+ const currentNode = path.join(home, ".nvm", "current", "lib", "node_modules", "shieldcortex");
75
152
  addRuntimeCandidate(candidates, currentNode);
76
153
  }
77
154
  catch { /* no nvm */ }
@@ -103,6 +180,16 @@ async function getRuntime() {
103
180
  lastError = error;
104
181
  }
105
182
  }
183
+ // #174: with every candidate screened by existsSync, "none found" is a
184
+ // real outcome and must not render as `Tried: . Last error: unknown
185
+ // error`. Name the escape hatch instead — this message is the only thing
186
+ // an operator on an unusual install prefix has to go on.
187
+ if (tried.length === 0) {
188
+ throw new Error("Could not load OpenClaw runtime: the shieldcortex package was not found from the plugin, " +
189
+ "and no known install prefix contained hooks/openclaw/cortex-memory/runtime.mjs. " +
190
+ "Point at it explicitly with SHIELDCORTEX_RUNTIME_PATH=/path/to/shieldcortex " +
191
+ "(or to the runtime.mjs itself), or reinstall so `shieldcortex` resolves as a peer of the plugin.");
192
+ }
106
193
  const detail = lastError instanceof Error ? lastError.message : String(lastError ?? "unknown error");
107
194
  throw new Error(`Could not load OpenClaw runtime. Tried: ${tried.join(", ")}. Last error: ${detail}`);
108
195
  })();
@@ -142,6 +229,12 @@ async function getDefenceModule() {
142
229
  export function __getSessionTaintForTest() {
143
230
  return sessionTaint;
144
231
  }
232
+ /** Test seam for #174 runtime resolution: `home` and the resolving module URL
233
+ * are injectable because Jest sandboxes SHIELDCORTEX_CONFIG_DIR but never
234
+ * HOME, so an un-injected probe would read the developer's real install. */
235
+ export function __collectRuntimeCandidatesForTest(home, from) {
236
+ return collectRuntimeCandidates(home, from);
237
+ }
145
238
  export function __setDefenceModuleForTest(mod) {
146
239
  _defenceModOverride = mod;
147
240
  _defenceModPromise = null;
@@ -155,13 +248,492 @@ export function __resetConfigStateForTest() {
155
248
  _config = null;
156
249
  _configOverride = null;
157
250
  _lastShieldConfigRef = null;
251
+ // Re-arm the once-per-load config-failure warning (#226).
252
+ _shieldConfigLoadFailureLogged = false;
158
253
  _registered = false;
159
254
  _beforeToolCallRegistered = false;
160
255
  _registrationError = null;
256
+ _beforeAgentRunRequested = false;
257
+ _conversationAccessGranted = false;
258
+ _gatewayNotifyContext = null;
259
+ _hostRuntimeVersion = null;
260
+ __resetScanUnavailableAlertState();
161
261
  }
162
262
  const INTERCEPT_SEVERITIES = ['low', 'medium', 'high', 'critical'];
163
263
  const INTERCEPT_ACTIONS = ['log', 'warn', 'require_approval'];
164
264
  const FAILURE_ACTIONS = ['allow', 'deny'];
265
+ const CONVERSATION_POSTURES = ['off', 'observe', 'enforce'];
266
+ /** Resolve the configured posture. Anything unrecognised resolves DOWN to
267
+ * `observe`, never up to `enforce`: a typo must not silently start blocking
268
+ * every turn on an operator's box. */
269
+ export function conversationPosture(raw) {
270
+ if (!raw || typeof raw !== 'object')
271
+ return 'observe';
272
+ const value = raw.posture;
273
+ return typeof value === 'string' && CONVERSATION_POSTURES.includes(value)
274
+ ? value
275
+ : 'observe';
276
+ }
277
+ /**
278
+ * The whole decision, as a pure function — no I/O, no hooks, so the posture
279
+ * semantics are testable directly and cannot drift as the plumbing changes.
280
+ *
281
+ * The key line is `notify` on a non-blocking detection: logging is not a sink.
282
+ * The #225 finding was that a HIGH verdict reached a log file and nothing else,
283
+ * so a real threat on a real box was seen by nobody. Under `observe` we still
284
+ * do not stop the turn — but a human hears about it.
285
+ *
286
+ * `trust` is the second input because BLOCKING is a consequence, and #235's rule
287
+ * is that source trust gates consequences (see conversation-trust.ts). It is
288
+ * optional, and its absence means "origin not established", which resolves
289
+ * toward enforcement rather than away from it: a caller that does not know who
290
+ * spoke has not proved the owner did.
291
+ */
292
+ export function evaluateConversationRun(posture, scan, trust) {
293
+ if (posture === 'off') {
294
+ return { block: false, notify: false, audit: false, reason: null, outcome: 'not-scanned' };
295
+ }
296
+ // Scanner failure fails OPEN — a broken scanner must not wedge every turn,
297
+ // which is the outcome ShieldCortex exists to prevent — but it is reported,
298
+ // because an unprotected turn must never read as a protected one. Note the
299
+ // condition: `available === false` OR the legacy `errored` flag, so a caller
300
+ // still constructing the old shape cannot route an unscanned turn into the
301
+ // clean branch.
302
+ if (scan.available === false || scan.errored) {
303
+ return {
304
+ block: false,
305
+ notify: true,
306
+ audit: true,
307
+ reason: `conversation scan unavailable (${scan.error ?? scan.summary}) — turn allowed UNSCANNED`,
308
+ outcome: 'unavailable',
309
+ };
310
+ }
311
+ if (scan.clean) {
312
+ return { block: false, notify: false, audit: false, reason: null, outcome: 'clean' };
313
+ }
314
+ // #235: the owner's own words are an instruction, so `enforce` does not act on
315
+ // them. This is the branch the whole trust module exists for — a block here
316
+ // does not warn the operator, it DESTROYS their message: OpenClaw keeps only
317
+ // the replacement text. Everything above still happened: the content was
318
+ // scanned, and `notify`/`audit` below are true whoever sent it. Only the
319
+ // consequence is withheld, and the reason says so on the row rather than
320
+ // leaving an enforce-posture host that did not block looking like a bug.
321
+ const trusted = trust !== undefined && !trust.mayTaint;
322
+ const block = posture === 'enforce' && !trusted;
323
+ return {
324
+ block,
325
+ notify: true,
326
+ audit: true,
327
+ reason: posture === 'enforce' && trusted
328
+ ? `conversation threat: ${scan.summary} — NOT blocked: ${trust.reason}`
329
+ : `conversation threat: ${scan.summary}`,
330
+ outcome: block ? 'blocked' : 'observed',
331
+ };
332
+ }
333
+ // ==================== CONVERSATION PLANE: HOST SUPPORT + CONSENT ============
334
+ /**
335
+ * The first OpenClaw build whose plugin SDK declares the `before_agent_run`
336
+ * gate. Established by inspecting published npm artifacts, not by guessing:
337
+ *
338
+ * 2026.5.7 — `hook-types.d.ts` has no `before_agent_run` anywhere (0 hits);
339
+ * CONVERSATION_HOOK_NAMES = llm_input, llm_output,
340
+ * before_agent_finalize, agent_end
341
+ * 2026.5.9-beta.1 — FIRST published build declaring it: in `PLUGIN_HOOK_NAMES`,
342
+ * in `CONVERSATION_HOOK_NAMES`, in `PluginHookHandlerMap`, with
343
+ * `PluginHookBeforeAgentRunResult = InputGateDecision | void`
344
+ * 2026.5.12 — first STABLE (non-prerelease) build with it (2026.5.10 and
345
+ * 2026.5.12 published betas in between; there is no plain
346
+ * 2026.5.9 release)
347
+ *
348
+ * Below this floor `api.on('before_agent_run', …)` is accepted by the API and
349
+ * then DROPPED by the registry with an `unknown typed hook … ignored`
350
+ * diagnostic — it does not throw. So a version check is the only honest way to
351
+ * know, and claiming enforcement without one is exactly the class of false
352
+ * green #222 is about.
353
+ *
354
+ * ── ONE FLOOR, THREE FILES ────────────────────────────────────────────────
355
+ *
356
+ * The authoritative value is the STABLE release, and it is stated in three
357
+ * places that cannot import each other:
358
+ *
359
+ * plugins/openclaw/index.ts — this constant
360
+ * src/integrations/openclaw-conversation-capability.ts
361
+ * — CONVERSATION_ENFORCEMENT_MIN_OPENCLAW
362
+ * plugins/openclaw/openclaw.plugin.json — engines.conversationGate
363
+ *
364
+ * THE BOUNDARY IS REAL, not a preference. The plugin ships as its own dist,
365
+ * compiled by `tsconfig.openclaw-plugin.json` with `rootDir:
366
+ * ./plugins/openclaw` and an explicit `include` list; a `src/` import does not
367
+ * merely offend layering, it fails to emit — and the src module imports
368
+ * `semver`, which the plugin bundle does not carry (hence the hand-rolled
369
+ * `compareOpenClawVersions` below). The manifest is JSON read by the host and
370
+ * imports nothing at all.
371
+ *
372
+ * So the three are pinned EQUAL by test instead of shared by import:
373
+ * `src/__tests__/conversation-gate-floor-parity-226.test.ts` reads all three
374
+ * and fails on drift. Change one, that test tells you about the other two.
375
+ *
376
+ * `CONVERSATION_GATE_FIRST_PRERELEASE_OPENCLAW` is deliberately SUBORDINATE: it
377
+ * decides nothing an operator sees. Its only job is to mark the band where a
378
+ * version number alone cannot answer the question — see
379
+ * `hostSupportsConversationGate`.
380
+ */
381
+ export const CONVERSATION_GATE_MIN_OPENCLAW = '2026.5.12';
382
+ /**
383
+ * Documentation of when the hook first appeared, NOT a second floor.
384
+ *
385
+ * The previous cut used this as the support threshold, which made the plugin's
386
+ * operator-facing verdict disagree with the CLI's on any 2026.5.9-beta.1 →
387
+ * 2026.5.11 host: `shieldcortex doctor` said enforcement was unavailable while
388
+ * the plugin's own status line said supported. Two answers to one question is
389
+ * how the next false green gets built.
390
+ */
391
+ export const CONVERSATION_GATE_FIRST_PRERELEASE_OPENCLAW = '2026.5.9-beta.1';
392
+ /**
393
+ * Compare two OpenClaw CalVer strings (`2026.5.12`, `2026.5.9-beta.1`).
394
+ * Deliberately local and tiny: the plugin build cannot import semver, and the
395
+ * only question asked is "is this host at or above the floor".
396
+ * Returns null when either side cannot be parsed — "unknown", never "yes".
397
+ */
398
+ export function compareOpenClawVersions(a, b) {
399
+ const parse = (v) => {
400
+ // Exactly three numeric parts, and only `-` introduces a prerelease. A
401
+ // trailing `.4` is NOT a prerelease tail — it is a version shape we do not
402
+ // understand, and the safe answer to that is "unknown".
403
+ const m = String(v ?? '').trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
404
+ if (!m)
405
+ return null;
406
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split('.') : [] };
407
+ };
408
+ const pa = parse(a);
409
+ const pb = parse(b);
410
+ if (!pa || !pb)
411
+ return null;
412
+ for (let i = 0; i < 3; i++) {
413
+ if (pa.nums[i] !== pb.nums[i])
414
+ return pa.nums[i] < pb.nums[i] ? -1 : 1;
415
+ }
416
+ // A prerelease sorts BELOW the same numeric release (2026.5.9-beta.1 < 2026.5.9).
417
+ if (pa.pre.length === 0 && pb.pre.length === 0)
418
+ return 0;
419
+ if (pa.pre.length === 0)
420
+ return 1;
421
+ if (pb.pre.length === 0)
422
+ return -1;
423
+ return comparePrerelease(pa.pre, pb.pre);
424
+ }
425
+ /** Semver prerelease precedence, restricted to what a CalVer tail can hold:
426
+ * numeric identifiers compare numerically, a numeric identifier sorts below an
427
+ * alphanumeric one, and a shorter identifier list sorts below an
428
+ * otherwise-equal longer one (`beta` < `beta.1` < `beta.2` < `beta.10`).
429
+ *
430
+ * The string compare this replaces put `beta.10` BELOW `beta.1`, so the tenth
431
+ * beta of the gate build was classified as predating the first — an error in
432
+ * the one direction this file must never make, since it demotes a host that
433
+ * HAS the gate to 'unsupported'. */
434
+ function comparePrerelease(a, b) {
435
+ const len = Math.max(a.length, b.length);
436
+ for (let i = 0; i < len; i++) {
437
+ const x = a[i];
438
+ const y = b[i];
439
+ if (x === undefined)
440
+ return -1;
441
+ if (y === undefined)
442
+ return 1;
443
+ const xNum = /^\d+$/.test(x);
444
+ const yNum = /^\d+$/.test(y);
445
+ if (xNum && yNum) {
446
+ const nx = Number(x);
447
+ const ny = Number(y);
448
+ if (nx !== ny)
449
+ return nx < ny ? -1 : 1;
450
+ continue;
451
+ }
452
+ if (xNum !== yNum)
453
+ return xNum ? -1 : 1;
454
+ if (x !== y)
455
+ return x < y ? -1 : 1;
456
+ }
457
+ return 0;
458
+ }
459
+ /** Test seam: pins the host probe without touching disk. */
460
+ let _hostProbeOverride;
461
+ export function __setHostOpenClawProbeForTest(p) {
462
+ _hostProbeOverride = p;
463
+ _hostProbeCache = undefined;
464
+ }
465
+ let _hostProbeCache;
466
+ /**
467
+ * The host runtime version the gateway told us about, captured at register().
468
+ *
469
+ * `api.runtime.version` is declared by the host SDK as
470
+ * `PluginRuntimeCore.version: string` — the version of the OpenClaw runtime
471
+ * this plugin is loaded into (verified against the installed host's
472
+ * `dist/plugin-sdk/src/plugins/runtime/types-core.d.ts`, and `api.runtime` is
473
+ * on `OpenClawPluginApi` in the same build). It is NOT `api.version`, which is
474
+ * the plugin's own version and would answer a completely different question:
475
+ * comparing OUR version against an OpenClaw floor would classify every host as
476
+ * unsupported.
477
+ *
478
+ * It is preferred over the filesystem walk because it is the running process
479
+ * describing itself, where the walk infers from whichever package.json happens
480
+ * to sit above the entry path. Null until a host actually supplies it — an
481
+ * older gateway, a CLI invocation or a test rig may not, and that is UNKNOWN.
482
+ */
483
+ let _hostRuntimeVersion = null;
484
+ /** Test seam for the runtime-supplied host version. */
485
+ export function __setHostRuntimeVersionForTest(v) {
486
+ _hostRuntimeVersion = v;
487
+ }
488
+ /**
489
+ * Record `api.runtime.version` if this host exposes it. Returns what was
490
+ * recorded (null when nothing usable was offered), and never throws: a host
491
+ * with an exotic `runtime` getter must not take the plugin's registration down.
492
+ */
493
+ export function recordHostRuntimeVersion(api) {
494
+ try {
495
+ const runtime = api?.runtime;
496
+ const version = runtime?.version;
497
+ _hostRuntimeVersion = typeof version === 'string' && version.trim() ? version.trim() : null;
498
+ }
499
+ catch {
500
+ _hostRuntimeVersion = null;
501
+ }
502
+ return _hostRuntimeVersion;
503
+ }
504
+ /** Does this host's shipped SDK declare the gate? Bounded, best-effort, and
505
+ * null on anything unexpected — an unreadable install is UNKNOWN, never
506
+ * "supported". */
507
+ function probeGateDeclaration(root) {
508
+ const candidates = [];
509
+ // 2026.5.2-era layout: the declarations live under the plugin-sdk tree.
510
+ candidates.push(path.join(root, 'dist', 'plugin-sdk', 'src', 'plugins', 'hook-types.d.ts'));
511
+ // 2026.6+/2026.7 layout: a single hashed `hook-types-<hash>.d.ts` at dist root.
512
+ try {
513
+ const distDir = path.join(root, 'dist');
514
+ if (existsSync(distDir)) {
515
+ for (const name of readdirSync(distDir)) {
516
+ if (/^hook-types.*\.d\.ts$/.test(name))
517
+ candidates.push(path.join(distDir, name));
518
+ }
519
+ }
520
+ }
521
+ catch { /* fall through to whatever candidates we have */ }
522
+ let sawAny = false;
523
+ for (const file of candidates) {
524
+ try {
525
+ if (!existsSync(file))
526
+ continue;
527
+ sawAny = true;
528
+ if (/\bbefore_agent_run\b/.test(readFileSync(file, 'utf-8')))
529
+ return true;
530
+ }
531
+ catch { /* unreadable candidate — try the next */ }
532
+ }
533
+ return sawAny ? false : null;
534
+ }
535
+ /**
536
+ * Everything we can learn about the host OpenClaw build from inside the plugin.
537
+ *
538
+ * Two independent sources, both read, neither invented:
539
+ *
540
+ * - `api.runtime.version` — the running gateway's own statement of its
541
+ * version, captured at register() (see `recordHostRuntimeVersion`). This is
542
+ * the primary VERSION evidence when the host offers it. Note it is not
543
+ * `api.version`, which is this plugin's version.
544
+ * - the filesystem — walk up from the gateway's entry path to the package.json
545
+ * that names openclaw, then read that install's own shipped hook
546
+ * declarations. This is the version FALLBACK, and it is the only source of
547
+ * `declaresGate`, which stays the strongest gate-support evidence of the two
548
+ * (a backport or a fork answers it correctly where a version comparison
549
+ * cannot — see `hostSupportsConversationGate`).
550
+ *
551
+ * No process is ever spawned. A null everywhere is a legitimate, frequently
552
+ * correct answer (a CLI invocation, an unusual install layout) and callers must
553
+ * treat it as UNKNOWN — never as "supported".
554
+ */
555
+ export function detectHostOpenClaw() {
556
+ const disk = detectHostOpenClawFromDisk();
557
+ // The runtime's own version outranks whatever package.json the walk landed
558
+ // on — but only for the version; `declaresGate` and `root` are disk facts and
559
+ // are carried through untouched.
560
+ if (_hostRuntimeVersion)
561
+ return { ...disk, version: _hostRuntimeVersion, versionSource: 'runtime' };
562
+ return disk;
563
+ }
564
+ function detectHostOpenClawFromDisk() {
565
+ if (_hostProbeOverride !== undefined)
566
+ return _hostProbeOverride ?? { version: null, root: null, declaresGate: null };
567
+ if (_hostProbeCache !== undefined)
568
+ return _hostProbeCache;
569
+ _hostProbeCache = (() => {
570
+ const empty = { version: null, root: null, declaresGate: null, versionSource: null };
571
+ const entry = process.argv?.[1];
572
+ if (!entry || typeof entry !== 'string')
573
+ return empty;
574
+ let current;
575
+ try {
576
+ current = path.dirname(realpathSync(entry));
577
+ }
578
+ catch {
579
+ current = path.dirname(entry);
580
+ }
581
+ let previous = '';
582
+ for (let i = 0; i < 8 && current !== previous; i++) {
583
+ try {
584
+ const pkgPath = path.join(current, 'package.json');
585
+ if (existsSync(pkgPath)) {
586
+ const hostPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
587
+ if (hostPkg?.name === 'openclaw') {
588
+ const version = typeof hostPkg.version === 'string' ? hostPkg.version : null;
589
+ return {
590
+ version,
591
+ versionSource: version ? 'package.json' : null,
592
+ root: current,
593
+ declaresGate: probeGateDeclaration(current),
594
+ };
595
+ }
596
+ }
597
+ }
598
+ catch { /* keep walking up */ }
599
+ previous = current;
600
+ current = path.dirname(current);
601
+ }
602
+ return empty;
603
+ })();
604
+ return _hostProbeCache;
605
+ }
606
+ /** Convenience for callers that only want the version string. */
607
+ export function detectHostOpenClawVersion() {
608
+ return detectHostOpenClaw().version;
609
+ }
610
+ /**
611
+ * Does this host have the `before_agent_run` gate at all?
612
+ *
613
+ * Order matters: what the installed build DECLARES outranks what its version
614
+ * number implies, and both outrank a guess. There is no branch here that
615
+ * returns 'supported' without evidence.
616
+ */
617
+ export function hostSupportsConversationGate(probe) {
618
+ const resolved = typeof probe === 'string' || probe === null
619
+ ? { version: probe, root: null, declaresGate: null }
620
+ : probe;
621
+ if (resolved.declaresGate === true)
622
+ return 'supported';
623
+ if (resolved.declaresGate === false)
624
+ return 'unsupported';
625
+ if (!resolved.version)
626
+ return 'unknown';
627
+ const cmp = compareOpenClawVersions(resolved.version, CONVERSATION_GATE_MIN_OPENCLAW);
628
+ if (cmp === null)
629
+ return 'unknown';
630
+ if (cmp >= 0)
631
+ return 'supported';
632
+ // Below the STABLE floor. One band inside that is not honestly 'unsupported':
633
+ // 2026.5.9-beta.1 → 2026.5.11 ship the hook as a prerelease, so calling them
634
+ // unsupported would tell an operator "no posture can block a turn on this
635
+ // host" about a host that blocks. The opposite claim is worse still, so
636
+ // neither is made: this is the absence of a measurement, and
637
+ // `describeConversationPlane` renders it as UNPROVEN and active:false.
638
+ //
639
+ // In practice a real prerelease install lands on `declaresGate` above and
640
+ // never reaches here — this branch is what happens when the declarations
641
+ // could not be read either, i.e. when we genuinely do not know.
642
+ const pre = compareOpenClawVersions(resolved.version, CONVERSATION_GATE_FIRST_PRERELEASE_OPENCLAW);
643
+ if (pre === null)
644
+ return 'unknown';
645
+ return pre >= 0 ? 'unknown' : 'unsupported';
646
+ }
647
+ /**
648
+ * Read the operator's CONVERSATION-ACCESS consent for this plugin from the
649
+ * host config: `plugins.entries.<id>.hooks.allowConversationAccess === true`.
650
+ *
651
+ * OpenClaw refuses every conversation hook for a non-bundled plugin without
652
+ * this exact value (registry: `record.origin !== "bundled" &&
653
+ * explicitConversationAccess !== true`). `llm_input` and `llm_output` are on
654
+ * that list in every build; `before_agent_run` joins it in 2026.5.9-beta.1,
655
+ * the same build that first declares the gate at all — so from there on the
656
+ * grant governs the conversation firewall's enforcement point too.
657
+ * Strict `true` only, matching the host: `undefined` and `false` are the same
658
+ * refusal there, and reading them differently here would report protection the
659
+ * gateway is not providing.
660
+ *
661
+ * It is the operator's per-box CONSENT grant, and this plugin only ever READS
662
+ * it. Nothing on the plugin's own path — `register()`, a hook, a background
663
+ * refresh — may write it: a security product that silently grants itself the
664
+ * right to read every conversation is the behaviour this product exists to
665
+ * catch. The only thing that may set it is an explicit, operator-initiated
666
+ * install/repair that says so out loud (#225: "the installer must never set it
667
+ * silently"). Absence is therefore reported, loudly and by name, rather than
668
+ * fixed from in here.
669
+ */
670
+ export function readConversationAccessGrant(rootConfig) {
671
+ if (!rootConfig || typeof rootConfig !== 'object' || Array.isArray(rootConfig))
672
+ return false;
673
+ const entries = rootConfig.plugins?.entries;
674
+ const entry = entries?.[PLUGIN_ID] ?? entries?.[PLUGIN_PACKAGE_NAME];
675
+ return entry?.hooks?.allowConversationAccess === true;
676
+ }
677
+ export function describeConversationPlane(input) {
678
+ const { posture, hookRequested, gateSupport, hostOpenClawVersion, consentGranted } = input;
679
+ const hostText = hostOpenClawVersion ? `OpenClaw ${hostOpenClawVersion}` : 'OpenClaw version undetermined';
680
+ if (posture === 'off') {
681
+ return {
682
+ ...input,
683
+ active: false,
684
+ summary: 'off — conversation scanning disabled by config (interceptor.conversation.posture=off)',
685
+ };
686
+ }
687
+ if (!consentGranted) {
688
+ return {
689
+ ...input,
690
+ active: false,
691
+ summary: `INACTIVE: conversation access NOT granted on this host — set plugins.entries.${PLUGIN_ID}.hooks.allowConversationAccess=true ` +
692
+ 'in openclaw.json (operator consent; the installer will never set it for you). Until then the gateway REFUSES llm_input and ' +
693
+ 'llm_output for this plugin — and, on builds that have it, before_agent_run too: nothing on the conversation path is scanned or blocked',
694
+ };
695
+ }
696
+ if (gateSupport === 'unsupported') {
697
+ return {
698
+ ...input,
699
+ active: false,
700
+ summary: `INACTIVE for enforcement: ${hostText} predates the before_agent_run gate ` +
701
+ `(floor ${CONVERSATION_GATE_MIN_OPENCLAW}; first seen as a prerelease in ${CONVERSATION_GATE_FIRST_PRERELEASE_OPENCLAW}) — ` +
702
+ 'observation only; no posture can block a turn on this host',
703
+ };
704
+ }
705
+ if (!hookRequested) {
706
+ return { ...input, active: false, summary: 'INACTIVE: the before_agent_run hook was not registered this session' };
707
+ }
708
+ if (gateSupport === 'unknown') {
709
+ // UNPROVEN IS NOT ACTIVE. Every other branch above is a fact we read — the
710
+ // posture, the grant, the host build. This one is the absence of a
711
+ // measurement: we could not establish that this host has the gate at all,
712
+ // and `api.on` does not acknowledge a registration, so nothing here knows
713
+ // whether the hook exists. Reporting `active: true` with a caveat glued to
714
+ // the summary string — which is what this did — means every caller that
715
+ // reads the boolean instead of the prose (status renderers, doctor, any
716
+ // future check) claims a live firewall on evidence nobody has. Under
717
+ // `enforce` that is the worst version of it: the operator believes turns
718
+ // are being blocked on a host where the gate may be silently dropped.
719
+ return {
720
+ ...input,
721
+ active: false,
722
+ summary: `UNPROVEN: could not verify that host ${hostText} provides the before_agent_run gate ` +
723
+ `(no runtime version, no readable hook declarations), and the plugin API does not acknowledge a registration. ` +
724
+ (posture === 'enforce'
725
+ ? 'The posture is enforce, so a dirty verdict WOULD block the run where the gate exists — but that it exists here is not established. Treat this host as observation-only until it is.'
726
+ : 'Detections are audited and sent to the operator where the hook runs at all; nothing is blocked in this posture regardless.'),
727
+ };
728
+ }
729
+ return {
730
+ ...input,
731
+ active: true,
732
+ summary: posture === 'enforce'
733
+ ? 'enforce — a dirty verdict BLOCKS the run via before_agent_run'
734
+ : 'observe — detections are audited and sent to the operator; turns are NOT blocked',
735
+ };
736
+ }
165
737
  const PLUGIN_ID = "shieldcortex-realtime";
166
738
  /**
167
739
  * #233: conversation-level taint, shared between the conversation scan (which
@@ -213,6 +785,21 @@ const PLUGIN_CONFIG_UI_HINTS = {
213
785
  label: "Enable Tool Call Interceptor",
214
786
  help: "Scan memory-write tool calls and gate suspicious content behind user approval.",
215
787
  },
788
+ // #226: these two exist in openclaw.plugin.json's uiHints and were missing
789
+ // here, so the host UI and the plugin's own declared hints described
790
+ // different sets of settings. The manifest parity test now pins the two key
791
+ // sets EQUAL in both directions, because a hint present on only one side is
792
+ // a setting one surface documents and the other silently omits.
793
+ "interceptor.severityActions.high": {
794
+ label: "High Severity Action",
795
+ help: "Action for high-severity threats: log, warn, or require_approval.",
796
+ advanced: true,
797
+ },
798
+ "interceptor.severityActions.critical": {
799
+ label: "Critical Severity Action",
800
+ help: "Action for critical-severity threats: log, warn, or require_approval.",
801
+ advanced: true,
802
+ },
216
803
  "interceptor.actionGuard.enabled": {
217
804
  label: "Action Guard",
218
805
  help: "Gate dangerous shell/file/network/git tool calls before they execute. Catastrophic operations are always blocked while enabled.",
@@ -241,6 +828,38 @@ const PLUGIN_CONFIG_UI_HINTS = {
241
828
  help: "Off = every dangerous-tier action still waits for you; the broker can then only harden, never release.",
242
829
  advanced: true,
243
830
  },
831
+ // #225. The posture is the whole product claim on the conversation path, so
832
+ // it is NOT marked advanced: an operator must be able to see, in the UI that
833
+ // configures this plugin, whether the firewall in front of their prompts can
834
+ // stop anything.
835
+ "interceptor.conversation.posture": {
836
+ label: "Conversation Firewall",
837
+ help: "What the conversation firewall does with a detection on the input path. " +
838
+ "off = do not scan; observe = scan, audit and alert the operator but never stop the turn (default); " +
839
+ "enforce = block the run via before_agent_run. Requires plugins.entries.shieldcortex-realtime.hooks.allowConversationAccess=true " +
840
+ "on this host — OpenClaw refuses conversation hooks without that operator grant, and ShieldCortex will never set it for you.",
841
+ },
842
+ "interceptor.actionGuard.notify.enabled": {
843
+ label: "Operator Notifications",
844
+ help: "Reach a human when the guard holds an action, or when the conversation firewall detects a threat. Off by default.",
845
+ advanced: true,
846
+ },
847
+ "interceptor.actionGuard.notify.webhookUrl": {
848
+ label: "Notify Webhook URL",
849
+ help: "http(s) endpoint the notification is POSTed to. Conversation-firewall alerts carry no approve/deny affordance — there is nothing to approve.",
850
+ advanced: true,
851
+ },
852
+ "interceptor.actionGuard.notify.webhookSecret": {
853
+ label: "Notify Webhook Secret",
854
+ help: "HMAC-SHA256 key for X-ShieldCortex-Signature, so the receiver can reject spoofed POSTs.",
855
+ sensitive: true,
856
+ advanced: true,
857
+ },
858
+ "interceptor.actionGuard.notify.openclaw": {
859
+ label: "Notify via OpenClaw",
860
+ help: "Deliver through the gateway's own channel where the runtime provides that seam.",
861
+ advanced: true,
862
+ },
244
863
  };
245
864
  const SEVERITY_ACTION_SCHEMA = {
246
865
  type: "object",
@@ -252,63 +871,137 @@ const FAILURE_POLICY_SCHEMA = {
252
871
  additionalProperties: false,
253
872
  properties: Object.fromEntries(INTERCEPT_SEVERITIES.map((severity) => [severity, { type: "string", enum: [...FAILURE_ACTIONS] }])),
254
873
  };
255
- const INTERCEPTOR_JSON_SCHEMA = {
874
+ /** #225. Mirrored verbatim into openclaw.plugin.json's configSchema — the host
875
+ * validates the on-disk config against THAT file, so a posture accepted here
876
+ * and absent there is a config an operator writes from our own docs and the
877
+ * gateway rejects. */
878
+ const CONVERSATION_JSON_SCHEMA = {
879
+ type: "object",
880
+ additionalProperties: false,
881
+ properties: {
882
+ posture: {
883
+ type: "string",
884
+ enum: [...CONVERSATION_POSTURES],
885
+ default: "observe",
886
+ description: "off = do not scan the conversation; observe = scan, audit and alert but never block (default); " +
887
+ "enforce = block the run on a dirty verdict via before_agent_run.",
888
+ },
889
+ },
890
+ };
891
+ /** #235/#226. Mirrored verbatim into openclaw.plugin.json's configSchema, for
892
+ * the same reason as the conversation posture above: the host validates the
893
+ * on-disk config against THAT file, and a key our parser reads but neither
894
+ * schema declares is one an operator cannot set at all. */
895
+ const CONVERSATION_TRUST_JSON_SCHEMA = {
896
+ type: "object",
897
+ additionalProperties: false,
898
+ properties: {
899
+ trustOwnerInput: {
900
+ type: "boolean",
901
+ default: true,
902
+ description: "Default true: a message the host attributes to the gateway OWNER is an instruction, so a detection in it " +
903
+ "is audited and alerted but never taints the session or blocks the turn. Set false on a host where the owner " +
904
+ "routinely pastes untrusted content and you would rather have the caution than the quiet. Content from " +
905
+ "anyone else — including another agent on a trusted channel — is data regardless of this setting.",
906
+ },
907
+ },
908
+ };
909
+ /**
910
+ * The Action Guard block, declared ONCE and mounted in BOTH places the parser
911
+ * accepts it (#226).
912
+ *
913
+ * `normaliseConfig` has read a TOP-LEVEL `actionGuard` since #209 — that is the
914
+ * canonical location, and `interceptor.actionGuard` is the deprecated alias
915
+ * kept for pre-#209 configs. The schemas said the opposite: only the nested
916
+ * alias was declared, under `additionalProperties: false`, so a config written
917
+ * from our own documentation — `actionGuard.notify` at the top level — was
918
+ * rejected as an unknown key by any host that validates against the schema.
919
+ * The parser would have kept it; the config never reached the parser. That is
920
+ * the shape behind the original `parsedNotify: null` reproduction.
921
+ *
922
+ * One constant, two mount points, so the two can never drift. Mirrored by hand
923
+ * into openclaw.plugin.json's configSchema (the host validates the on-disk
924
+ * config against THAT file) and pinned equal by manifest-config-schema-226.test.ts.
925
+ */
926
+ const ACTION_GUARD_JSON_SCHEMA = {
256
927
  type: "object",
257
928
  additionalProperties: false,
258
929
  properties: {
259
930
  enabled: { type: "boolean" },
260
- severityActions: SEVERITY_ACTION_SCHEMA,
261
- failurePolicy: FAILURE_POLICY_SCHEMA,
262
- actionGuard: {
931
+ enforce: { type: "boolean" },
932
+ autoApprove: { type: "array", items: { type: "string" } },
933
+ auditAllows: { type: "boolean" },
934
+ // #143. Mirrors normaliseBrokerConfig's allowlist; that function still
935
+ // has the last word, so a value that slips past the schema is still
936
+ // range-checked (and dropped) before the broker sees it.
937
+ broker: {
263
938
  type: "object",
264
939
  additionalProperties: false,
265
940
  properties: {
266
941
  enabled: { type: "boolean" },
267
- enforce: { type: "boolean" },
268
- autoApprove: { type: "array", items: { type: "string" } },
269
- auditAllows: { type: "boolean" },
270
- // #143. Mirrors normaliseBrokerConfig's allowlist; that function still
271
- // has the last word, so a value that slips past the schema is still
272
- // range-checked (and dropped) before the broker sees it.
273
- broker: {
942
+ allowPreClear: { type: "boolean" },
943
+ preClearConfidence: { type: "number", minimum: 0.9, maximum: 1 },
944
+ judgeTimeoutMs: { type: "number", minimum: 500, maximum: 60000 },
945
+ approvalTimeoutMs: {
274
946
  type: "object",
275
947
  additionalProperties: false,
276
948
  properties: {
277
- enabled: { type: "boolean" },
278
- allowPreClear: { type: "boolean" },
279
- preClearConfidence: { type: "number", minimum: 0.9, maximum: 1 },
280
- judgeTimeoutMs: { type: "number", minimum: 500, maximum: 60000 },
281
- approvalTimeoutMs: {
282
- type: "object",
283
- additionalProperties: false,
284
- properties: {
285
- sensitive: { type: "number", minimum: 1000, maximum: 3600000 },
286
- dangerous: { type: "number", minimum: 1000, maximum: 3600000 },
287
- },
288
- },
289
- model: { type: "string" },
949
+ sensitive: { type: "number", minimum: 1000, maximum: 3600000 },
950
+ dangerous: { type: "number", minimum: 1000, maximum: 3600000 },
290
951
  },
291
952
  },
292
- // #189. Each entry pins one script by absolute path + content hash;
293
- // createReviewedScriptCheck has the last word on every field.
294
- reviewedScripts: {
295
- type: "array",
296
- items: {
297
- type: "object",
298
- additionalProperties: false,
299
- properties: {
300
- path: { type: "string" },
301
- sha256: { type: "string" },
302
- note: { type: "string" },
303
- addedAt: { type: "number" },
304
- },
305
- required: ["path", "sha256"],
306
- },
953
+ model: { type: "string" },
954
+ },
955
+ },
956
+ // #143/#225. Mirrors NotifyConfig in notify-config.ts, which still has
957
+ // the last word (strict-true booleans, http(s)-only URL, bounded
958
+ // timeout). Declared here because `additionalProperties: false` above
959
+ // means an undeclared key makes the WHOLE containing block invalid on
960
+ // a host that validates config against this schema — which is how the
961
+ // Action Guard's notify transport came to be unusable from inside the
962
+ // gateway plugin at all.
963
+ notify: {
964
+ type: "object",
965
+ additionalProperties: false,
966
+ properties: {
967
+ enabled: { type: "boolean" },
968
+ webhookUrl: { type: "string" },
969
+ webhookSecret: { type: "string" },
970
+ openclaw: { type: "boolean" },
971
+ timeoutMs: { type: "number", minimum: 500, maximum: 60000 },
972
+ },
973
+ },
974
+ // #189. Each entry pins one script by absolute path + content hash;
975
+ // createReviewedScriptCheck has the last word on every field.
976
+ reviewedScripts: {
977
+ type: "array",
978
+ items: {
979
+ type: "object",
980
+ additionalProperties: false,
981
+ properties: {
982
+ path: { type: "string" },
983
+ sha256: { type: "string" },
984
+ note: { type: "string" },
985
+ addedAt: { type: "number" },
307
986
  },
987
+ required: ["path", "sha256"],
308
988
  },
309
989
  },
310
990
  },
311
991
  };
992
+ const INTERCEPTOR_JSON_SCHEMA = {
993
+ type: "object",
994
+ additionalProperties: false,
995
+ properties: {
996
+ enabled: { type: "boolean" },
997
+ severityActions: SEVERITY_ACTION_SCHEMA,
998
+ failurePolicy: FAILURE_POLICY_SCHEMA,
999
+ conversation: CONVERSATION_JSON_SCHEMA,
1000
+ /** The DEPRECATED alias (#209). Still accepted, still parsed, still
1001
+ * gap-fills the canonical top-level block key by key. */
1002
+ actionGuard: ACTION_GUARD_JSON_SCHEMA,
1003
+ },
1004
+ };
312
1005
  const PLUGIN_CONFIG_JSON_SCHEMA = {
313
1006
  type: "object",
314
1007
  additionalProperties: false,
@@ -331,6 +1024,15 @@ const PLUGIN_CONFIG_JSON_SCHEMA = {
331
1024
  // #112: without this, `additionalProperties: false` declared the whole
332
1025
  // interceptor block invalid — mirror openclaw.plugin.json's configSchema.
333
1026
  interceptor: INTERCEPTOR_JSON_SCHEMA,
1027
+ // #209/#226: the CANONICAL Action Guard location. normaliseConfig has read
1028
+ // it here since #209 and folds it over the nested alias; the schema did not
1029
+ // declare it, so `additionalProperties: false` rejected the documented
1030
+ // config shape before the parser ever saw it.
1031
+ actionGuard: ACTION_GUARD_JSON_SCHEMA,
1032
+ // #235/#226: source trust. Same reason as `actionGuard` above — the parser
1033
+ // reads it, so the schema must declare it or `additionalProperties: false`
1034
+ // rejects the whole config an operator writes from our documentation.
1035
+ conversationTrust: CONVERSATION_TRUST_JSON_SCHEMA,
334
1036
  },
335
1037
  };
336
1038
  let _config = null;
@@ -369,6 +1071,17 @@ let _registered = false;
369
1071
  // unattended Codex agents even a no-op registered hook changes how OpenClaw
370
1072
  // resolves approvals).
371
1073
  let _beforeToolCallRegistered = false;
1074
+ // #225: whether we CALLED api.on('before_agent_run', …) this session — nothing
1075
+ // more. It is deliberately not named "…Accepted": `api.on` returns void, never
1076
+ // throws on an unknown hook name, and never throws when the host refuses a
1077
+ // conversation hook (it records a diagnostic and returns), so acceptance is not
1078
+ // observable from in here. The two facts that decide whether the plane is live
1079
+ // — host build ≥ the gate's floor, and the operator's allowConversationAccess
1080
+ // grant — are read separately and reported by describeConversationPlane().
1081
+ let _beforeAgentRunRequested = false;
1082
+ // The operator's conversation-access grant as read from the host config at
1083
+ // register() time. Reported by /shieldcortex-status; never written by us.
1084
+ let _conversationAccessGranted = false;
372
1085
  // #134 §2: register() wraps its whole body in try/catch so a plugin failure
373
1086
  // never blocks channel startup — correct, but it used to report the failure
374
1087
  // with a bare console.warn (bypasses the gateway's structured log, so the
@@ -421,6 +1134,20 @@ function normaliseConfig(raw, dropped) {
421
1134
  const interceptor = normaliseInterceptorConfig(value.interceptor, dropped);
422
1135
  if (interceptor)
423
1136
  config.interceptor = interceptor;
1137
+ // Source trust (#235, wired to the gate in #226). Booleans only, and the
1138
+ // block is kept only when it holds a valid one: a `trustOwnerInput: "false"`
1139
+ // typo — the exact shape of the #112 incident — must not read as the opt-out
1140
+ // having been applied, because the operator who wrote it believes owner input
1141
+ // is being policed and it would not be.
1142
+ if (value.conversationTrust && typeof value.conversationTrust === "object" && !Array.isArray(value.conversationTrust)) {
1143
+ const trustRaw = value.conversationTrust;
1144
+ if (typeof trustRaw.trustOwnerInput === "boolean") {
1145
+ config.conversationTrust = { trustOwnerInput: trustRaw.trustOwnerInput };
1146
+ }
1147
+ else if (trustRaw.trustOwnerInput !== undefined) {
1148
+ dropped?.push("conversationTrust.trustOwnerInput");
1149
+ }
1150
+ }
424
1151
  // #209: single source of truth for the Action Guard. A top-level
425
1152
  // `actionGuard` block governs every surface; `interceptor.actionGuard` is a
426
1153
  // deprecated alias kept as per-key gap-fill so pre-#209 configs keep their
@@ -521,6 +1248,20 @@ function normaliseActionGuardBlock(raw, dropped, pathPrefix) {
521
1248
  if (Array.isArray(rawGuard.reviewedScripts)) {
522
1249
  guard.reviewedScripts = [...rawGuard.reviewedScripts];
523
1250
  }
1251
+ // #143/#225: the notify transport. Same passthrough discipline again —
1252
+ // normaliseNotifyConfig is the boundary. Shallow-copied rather than aliased
1253
+ // (the #115 reason: a later in-place mutation of the host config object must
1254
+ // not reach into the normalised one), and a non-object is DROPPED by name so
1255
+ // the #115 warn log can say which key was ignored, rather than silently
1256
+ // leaving the operator with a transport that never fires.
1257
+ if (rawGuard.notify !== undefined) {
1258
+ if (rawGuard.notify && typeof rawGuard.notify === 'object' && !Array.isArray(rawGuard.notify)) {
1259
+ guard.notify = { ...rawGuard.notify };
1260
+ }
1261
+ else {
1262
+ dropped?.push(`${pathPrefix}.notify`);
1263
+ }
1264
+ }
524
1265
  return Object.keys(guard).length > 0 ? guard : undefined;
525
1266
  }
526
1267
  function normaliseInterceptorConfig(raw, dropped) {
@@ -543,6 +1284,25 @@ function normaliseInterceptorConfig(raw, dropped) {
543
1284
  const actionGuard = normaliseActionGuardBlock(value.actionGuard, dropped, "interceptor.actionGuard");
544
1285
  if (actionGuard)
545
1286
  out.actionGuard = actionGuard;
1287
+ // #225: conversation posture. An invalid value is DROPPED (and named in the
1288
+ // warn log) rather than coerced, so `conversationPosture()` falls back to
1289
+ // `observe` — the safe direction. A typo must never start blocking turns.
1290
+ if (value.conversation !== undefined) {
1291
+ if (value.conversation && typeof value.conversation === "object" && !Array.isArray(value.conversation)) {
1292
+ const posture = value.conversation.posture;
1293
+ if (posture !== undefined) {
1294
+ if (typeof posture === "string" && CONVERSATION_POSTURES.includes(posture)) {
1295
+ out.conversation = { posture: posture };
1296
+ }
1297
+ else {
1298
+ dropped?.push("interceptor.conversation.posture");
1299
+ }
1300
+ }
1301
+ }
1302
+ else {
1303
+ dropped?.push("interceptor.conversation");
1304
+ }
1305
+ }
546
1306
  // #115: empty/all-invalid normalises to undefined, not {} — {} is truthy
547
1307
  // and made applyPluginConfigOverride treat a no-op interceptor block as a
548
1308
  // real override, inconsistent with normaliseSeverityMap's own contract.
@@ -554,6 +1314,19 @@ function normaliseInterceptorConfig(raw, dropped) {
554
1314
  * - `interceptor` deep-merges PER KEY — per severity entry, per guard flag —
555
1315
  * so an override that sets one nested key does not wholesale-discard the
556
1316
  * base's other interceptor settings.
1317
+ * - `actionGuard.notify` and `actionGuard.broker` deep-merge per key too
1318
+ * (#226). They are the two OBJECT-valued guard keys, and a shallow spread
1319
+ * replaced them wholesale: an openclaw.json entry that says only
1320
+ * `notify: { enabled: true }` — the shape the UI writes when an operator
1321
+ * ticks "Operator Notifications" — discarded the shield config's
1322
+ * `webhookUrl` and `webhookSecret`, leaving notify ARMED with no channel and
1323
+ * no signing key. Every alert then reported "enabled but no channel is
1324
+ * configured/buildable on this host", which is the #143 silent-no-sink
1325
+ * failure in a new place.
1326
+ * - ARRAY-valued keys (`autoApprove`, `reviewedScripts`) still REPLACE. They
1327
+ * are allowlists: merging two of them would union permissions an operator
1328
+ * removed back into the effective config, which is the wrong direction for a
1329
+ * security control.
557
1330
  * - Explicit values, including `false`, always win over base values; absent
558
1331
  * keys fall through to the base.
559
1332
  * - Defaults are NOT applied here: DEFAULT_INTERCEPTOR_CONFIG only fills the
@@ -562,6 +1335,13 @@ function normaliseInterceptorConfig(raw, dropped) {
562
1335
  */
563
1336
  function mergeConfigs(base, override) {
564
1337
  const merged = { ...base, ...override };
1338
+ // Per-key, like every other nested block here. A plain spread would let an
1339
+ // openclaw.json entry that mentions `conversationTrust` at all replace the
1340
+ // shield-config block wholesale, silently reverting an opt-out set in the
1341
+ // file the operator considers authoritative.
1342
+ if (base.conversationTrust || override.conversationTrust) {
1343
+ merged.conversationTrust = { ...base.conversationTrust, ...override.conversationTrust };
1344
+ }
565
1345
  if (base.interceptor || override.interceptor) {
566
1346
  const b = base.interceptor ?? {};
567
1347
  const o = override.interceptor ?? {};
@@ -573,7 +1353,14 @@ function mergeConfigs(base, override) {
573
1353
  merged.interceptor.failurePolicy = { ...b.failurePolicy, ...o.failurePolicy };
574
1354
  }
575
1355
  if (b.actionGuard || o.actionGuard) {
576
- merged.interceptor.actionGuard = { ...b.actionGuard, ...o.actionGuard };
1356
+ const bg = b.actionGuard ?? {};
1357
+ const og = o.actionGuard ?? {};
1358
+ const guard = { ...bg, ...og };
1359
+ if (bg.notify || og.notify)
1360
+ guard.notify = { ...bg.notify, ...og.notify };
1361
+ if (bg.broker || og.broker)
1362
+ guard.broker = { ...bg.broker, ...og.broker };
1363
+ merged.interceptor.actionGuard = guard;
577
1364
  }
578
1365
  }
579
1366
  return merged;
@@ -610,8 +1397,58 @@ function applyPluginConfigOverride(api) {
610
1397
  _config = null;
611
1398
  _lastShieldConfigRef = null;
612
1399
  }
1400
+ /**
1401
+ * Load the effective config, DEGRADING rather than throwing (#226).
1402
+ *
1403
+ * `getRuntime()` resolves `shieldcortex/dist/…/runtime.mjs` by walking a list of
1404
+ * install locations, and `loadShieldConfig()` then reads a file. Both can fail
1405
+ * for ordinary reasons — the package was upgraded underneath a running gateway,
1406
+ * a global install moved, `~/.shieldcortex/config.json` is half-written.
1407
+ *
1408
+ * This used to propagate, and the propagation went somewhere bad: every caller
1409
+ * of `loadConfig` is a hook body, and in `handleBeforeAgentRun` the throw was
1410
+ * caught by the OUTER catch — the one that fails open. So a host that could not
1411
+ * load the runtime produced one console line per turn and NOTHING else: no
1412
+ * posture, no scan, no audit row, no alert. Precisely the "unprotected turn that
1413
+ * leaves no trace" the #225/#226 work exists to eliminate, reintroduced through
1414
+ * the config read rather than through the scanner.
1415
+ *
1416
+ * Now it degrades to the plugin config from openclaw.json (already normalised by
1417
+ * `applyPluginConfigOverride`), or to an empty config. The posture therefore
1418
+ * still resolves, the scan still runs, `scanRealtimeContent` reports UNAVAILABLE
1419
+ * on its own (the same runtime failure defeats the MCP fallback), and the gate
1420
+ * writes its normal audit row and raises its normal alert.
1421
+ *
1422
+ * It does NOT cache the degraded result — a later successful load must take
1423
+ * effect without a restart — and it never claims the shield config loaded: the
1424
+ * warning says exactly what is missing, and is bounded, redacted, and emitted
1425
+ * ONCE per plugin load (`__resetConfigStateForTest` re-arms it) so a per-turn
1426
+ * failure cannot become per-turn log spam.
1427
+ */
1428
+ let _shieldConfigLoadFailureLogged = false;
613
1429
  async function loadConfig() {
614
- const shieldConfigRaw = await (await getRuntime()).loadShieldConfig();
1430
+ let shieldConfigRaw;
1431
+ try {
1432
+ shieldConfigRaw = await (await getRuntime()).loadShieldConfig();
1433
+ }
1434
+ catch (err) {
1435
+ if (!_shieldConfigLoadFailureLogged) {
1436
+ _shieldConfigLoadFailureLogged = true;
1437
+ const detail = redactNotifyDetail(err instanceof Error ? err.message : String(err)).slice(0, 300);
1438
+ console.warn('[shieldcortex] ⚠️ shield config could NOT be loaded — the ShieldCortex runtime did not resolve, ' +
1439
+ `or it could not read ~/.shieldcortex/config.json (${detail}). Continuing with the openclaw.json ` +
1440
+ 'plugin config only; anything configured in the shield config file is NOT in effect, and ' +
1441
+ 'conversation scanning will report UNAVAILABLE until this is fixed. (Logged once per plugin load.)');
1442
+ }
1443
+ // A fresh object every time: `_configOverride` is module state and callers
1444
+ // must not be handed something they could mutate.
1445
+ return mergeConfigs({}, _configOverride ?? {});
1446
+ }
1447
+ // A load that succeeds after a failure re-arms the warning, so a SECOND
1448
+ // outage is reported rather than swallowed by the first one's flag. Set
1449
+ // before the cache check: a runtime that hands back the same object every
1450
+ // call would otherwise take the early return and leave the flag latched.
1451
+ _shieldConfigLoadFailureLogged = false;
615
1452
  if (_config && shieldConfigRaw === _lastShieldConfigRef)
616
1453
  return _config;
617
1454
  _lastShieldConfigRef = shieldConfigRaw;
@@ -643,33 +1480,127 @@ function parseScanResponse(response) {
643
1480
  const summary = detections ? `${risk} (${detections} detections)` : risk;
644
1481
  return { clean, summary };
645
1482
  }
1483
+ /**
1484
+ * Scan one piece of conversation content.
1485
+ *
1486
+ * The contract changed in #225 and the change is the point: an unavailable
1487
+ * scanner is now reported as `available: false, clean: false`, not as the old
1488
+ * `{ clean: true, summary: 'scan unavailable' }`. That old return was the
1489
+ * quietest bug in this file — the ORDINARY unavailable path (MCP fallback
1490
+ * returns nothing, e.g. no shieldcortex binary on PATH) manufactured a clean
1491
+ * verdict, so on any box where in-process defence failed to load, every message
1492
+ * was reported scanned-and-fine while nothing had been looked at.
1493
+ *
1494
+ * Fails OPEN — callers must not block on `available: false` — but LOUDLY: the
1495
+ * caller audits it, alerts on it, and doctor/status report the plane as
1496
+ * unavailable rather than protected.
1497
+ */
646
1498
  export async function scanRealtimeContent(text) {
647
1499
  // PRIMARY: scan in-process via the shared shieldcortex/defence module. The
648
1500
  // scan is pure (no DB handle required — scanToolResponse's audit write is
649
1501
  // guarded by isDatabaseInitialized()), so it is safe in the long-lived
650
1502
  // gateway and avoids booting a cold MCP server per message.
651
- const defenceMod = await getDefenceModule();
1503
+ let defenceMod = null;
1504
+ try {
1505
+ defenceMod = await getDefenceModule();
1506
+ }
1507
+ catch (err) {
1508
+ defenceMod = null;
1509
+ void err;
1510
+ }
652
1511
  if (defenceMod && typeof defenceMod.scanToolResponse === "function") {
653
- const scan = defenceMod.scanToolResponse("openclaw-realtime", text, "advisory");
654
- // Reproduce the historical summary contract exactly: risk level + detection
655
- // count only when the injection scan flagged something.
656
- const risk = scan.injection.clean ? "unknown" : scan.injection.riskLevel;
657
- const summary = scan.injection.clean
658
- ? risk
659
- : `${risk} (${scan.injection.detections.length} detections)`;
660
- return { clean: scan.clean, summary };
1512
+ try {
1513
+ const scan = defenceMod.scanToolResponse("openclaw-realtime", text, "advisory");
1514
+ // Reproduce the historical summary contract exactly: risk level + detection
1515
+ // count only when the injection scan flagged something.
1516
+ const risk = scan.injection.clean ? "unknown" : scan.injection.riskLevel;
1517
+ const summary = scan.injection.clean
1518
+ ? risk
1519
+ : `${risk} (${scan.injection.detections.length} detections)`;
1520
+ return { clean: scan.clean, summary, available: true };
1521
+ }
1522
+ catch (err) {
1523
+ // A scanner that THROWS is not a clean verdict either. Same treatment as
1524
+ // an absent one: unavailable, reported, never silently allowed to read as
1525
+ // protected.
1526
+ const detail = err instanceof Error ? err.message : String(err);
1527
+ return { clean: false, available: false, errored: true, error: `in-process scanner threw: ${detail}`, summary: "scan unavailable" };
1528
+ }
661
1529
  }
662
1530
  // FALLBACK: in-process defence unavailable (older install, import failed) —
663
1531
  // degrade to the MCP shell-out so scanning still happens rather than breaking.
664
- const response = await callCortex("scan_tool_response", {
665
- toolName: "openclaw-realtime",
666
- content: text,
667
- mode: "advisory",
668
- });
1532
+ let response = null;
1533
+ try {
1534
+ response = await callCortex("scan_tool_response", {
1535
+ toolName: "openclaw-realtime",
1536
+ content: text,
1537
+ mode: "advisory",
1538
+ });
1539
+ }
1540
+ catch (err) {
1541
+ const detail = err instanceof Error ? err.message : String(err);
1542
+ return { clean: false, available: false, errored: true, error: `scan fallback failed: ${detail}`, summary: "scan unavailable" };
1543
+ }
669
1544
  if (!response) {
670
- return { clean: true, summary: "scan unavailable" };
1545
+ return {
1546
+ clean: false,
1547
+ available: false,
1548
+ errored: true,
1549
+ error: 'no in-process defence module and the MCP fallback returned nothing',
1550
+ summary: 'scan unavailable',
1551
+ };
1552
+ }
1553
+ const parsed = parseScanResponse(response);
1554
+ return { ...parsed, available: true };
1555
+ }
1556
+ /**
1557
+ * `scanRealtimeContent` with a hard deadline (#226).
1558
+ *
1559
+ * Used ONLY by the `before_agent_run` gate, which the gateway awaits: an
1560
+ * unbounded scan there is an unbounded pause in front of the user's prompt. The
1561
+ * MCP fallback boots a cold server through `npx` and has been measured at ~15s,
1562
+ * so "it usually returns quickly" is not a bound.
1563
+ *
1564
+ * On expiry the result is an ordinary UNAVAILABLE verdict — fail open, audited,
1565
+ * alerted — and the error string names the deadline and NOTHING ELSE. It must
1566
+ * never quote the prompt: a timeout message is the one error a developer is
1567
+ * most likely to paste into an issue.
1568
+ *
1569
+ * The losing promise is not abandoned silently: a `.catch` is attached before
1570
+ * the race so a scan that rejects AFTER the deadline settles into a no-op
1571
+ * instead of an unhandled rejection that could take the gateway down under
1572
+ * `--unhandled-rejections=strict`.
1573
+ */
1574
+ export async function scanWithDeadline(text, timeoutMs = CONVERSATION_SCAN_MAX_MS) {
1575
+ const timedOut = {
1576
+ clean: false,
1577
+ available: false,
1578
+ errored: true,
1579
+ error: `conversation scan exceeded its ${timeoutMs}ms deadline`,
1580
+ summary: 'scan unavailable',
1581
+ };
1582
+ const scan = scanRealtimeContent(text);
1583
+ // Attached BEFORE the race, so a late rejection can never be unhandled.
1584
+ scan.catch(() => { });
1585
+ let timer;
1586
+ try {
1587
+ return await Promise.race([
1588
+ scan,
1589
+ new Promise((resolve) => {
1590
+ timer = setTimeout(() => resolve(timedOut), timeoutMs);
1591
+ // Never hold the process open on the deadline timer alone.
1592
+ timer.unref?.();
1593
+ }),
1594
+ ]);
1595
+ }
1596
+ catch (err) {
1597
+ const detail = err instanceof Error ? err.message : String(err);
1598
+ return { clean: false, available: false, errored: true, error: detail, summary: 'scan unavailable' };
1599
+ }
1600
+ finally {
1601
+ if (timer)
1602
+ clearTimeout(timer);
671
1603
  }
672
- return parseScanResponse(response);
673
1604
  }
674
1605
  // ==================== CONTENT PATTERNS ====================
675
1606
  const PATTERNS = {
@@ -725,17 +1656,52 @@ function extractUserContent(msgs) {
725
1656
  }
726
1657
  return out;
727
1658
  }
728
- const AUDIT_DIR = path.join(homedir(), ".shieldcortex", "audit");
1659
+ /** Where the realtime audit jsonl lives.
1660
+ *
1661
+ * Resolved PER CALL, and honouring `SHIELDCORTEX_AUDIT_DIR`, so a test can
1662
+ * exercise the hook end-to-end without appending fabricated "threat" rows to
1663
+ * a real box's security audit trail. Default is unchanged
1664
+ * (`~/.shieldcortex/audit`), so nothing moves for an install that does not set
1665
+ * the variable. */
1666
+ function auditDir() {
1667
+ const override = process.env.SHIELDCORTEX_AUDIT_DIR;
1668
+ if (override && override.trim())
1669
+ return override.trim();
1670
+ return path.join(homedir(), ".shieldcortex", "audit");
1671
+ }
729
1672
  const NOVELTY_CACHE_FILE = path.join(homedir(), ".shieldcortex", "openclaw-memory-cache.json");
730
1673
  const DEFAULT_NOVELTY_THRESHOLD = 0.88;
731
1674
  const DEFAULT_MAX_RECENT = 300;
732
1675
  const MIN_NOVELTY_CHARS = 40;
1676
+ /**
1677
+ * Append one row to the realtime audit jsonl. Returns WHETHER IT LANDED (#226).
1678
+ *
1679
+ * This used to swallow every failure and return void, so a caller could not
1680
+ * distinguish "the evidence is on disk" from "the disk is full / the audit dir
1681
+ * is not writable / it is a file where a directory should be". The
1682
+ * `before_agent_run` gate then wrote a decision row, got nothing back, and
1683
+ * proceeded to tell the operator — and the delivery row — that the decision was
1684
+ * recorded. A security control claiming evidence it does not have is worse than
1685
+ * one that admits the gap, because the gap is invisible in exactly the incident
1686
+ * where the log matters.
1687
+ *
1688
+ * Still never throws: a broken audit sink must not become a broken turn.
1689
+ */
733
1690
  async function auditLog(entry) {
1691
+ const dir = auditDir();
734
1692
  try {
735
- await fs.mkdir(AUDIT_DIR, { recursive: true });
736
- await fs.appendFile(path.join(AUDIT_DIR, `realtime-${new Date().toISOString().slice(0, 10)}.jsonl`), JSON.stringify(entry) + "\n");
1693
+ await fs.mkdir(dir, { recursive: true });
1694
+ await fs.appendFile(path.join(dir, `realtime-${new Date().toISOString().slice(0, 10)}.jsonl`), JSON.stringify(entry) + "\n");
1695
+ return true;
1696
+ }
1697
+ catch (err) {
1698
+ // LOUD. The detail is the failure and the directory — never the row, which
1699
+ // may carry a verdict summary, and never a credential (nothing in this path
1700
+ // holds one). Bounded so a pathological error message cannot flood stderr.
1701
+ const detail = err instanceof Error ? err.message : String(err);
1702
+ console.error(`[shieldcortex] ⚠️ AUDIT WRITE FAILED (${dir}) — this event is NOT on disk: ${detail.slice(0, 300)}`);
1703
+ return false;
737
1704
  }
738
- catch { }
739
1705
  }
740
1706
  function normalizeMemoryText(text) {
741
1707
  return String(text || "")
@@ -873,6 +1839,17 @@ function isInternalContent(text) {
873
1839
  // itself stays non-blocking.
874
1840
  export async function scanLlmInput(event, _ctx) {
875
1841
  try {
1842
+ // #226: THE POSTURE GOVERNS THIS HOOK TOO. `off` means "do not scan the
1843
+ // conversation at all", and this observation hook used to ignore it
1844
+ // entirely: it scanned every prompt, wrote threat and scan_unavailable rows,
1845
+ // and forwarded detections to the cloud on a box whose operator had
1846
+ // explicitly turned conversation inspection OFF. The gate honoured the
1847
+ // setting, so a reader of `handleBeforeAgentRun` would conclude the product
1848
+ // did too. Read it FIRST, before any scanner, any audit row and any cloud
1849
+ // call, so `off` costs exactly one config read.
1850
+ const cfg = await loadConfig();
1851
+ if (conversationPosture(cfg.interceptor?.conversation) === 'off')
1852
+ return;
876
1853
  // Only scan user content, skip system/boot/heartbeat prompts
877
1854
  // Trust is resolved per TURN, not per message: the host tells us who sent
878
1855
  // this turn, but history messages carry no individual attribution, so there
@@ -884,10 +1861,13 @@ export async function scanLlmInput(event, _ctx) {
884
1861
  let trustMemo = null;
885
1862
  const resolveTrust = async () => {
886
1863
  if (!trustMemo) {
1864
+ // `cfg` is already in hand from the posture read above, so this costs
1865
+ // no I/O. It also reads the PARSED field rather than casting the config
1866
+ // object — the cast this replaces asserted a key that normaliseConfig
1867
+ // drops, so it was always undefined. See SCConfig.conversationTrust.
887
1868
  trustMemo = classifyConversationOrigin({
888
1869
  senderIsOwner: event.senderIsOwner,
889
- trustOwnerInput: (await loadConfig())
890
- ?.conversationTrust?.trustOwnerInput,
1870
+ trustOwnerInput: cfg.conversationTrust?.trustOwnerInput,
891
1871
  });
892
1872
  }
893
1873
  return trustMemo;
@@ -898,6 +1878,33 @@ export async function scanLlmInput(event, _ctx) {
898
1878
  if (!text || text.length < 10)
899
1879
  continue;
900
1880
  const result = await scanRealtimeContent(text);
1881
+ // #225: "we could not look" is its own outcome. Before this branch the
1882
+ // unavailable path returned clean:true and this loop did nothing at all —
1883
+ // an unscanned message was indistinguishable from a scanned one, on the
1884
+ // observation hook as well as the gate.
1885
+ if (!result.available) {
1886
+ // #226: redacted on the CONSOLE too, not only in the row. The reason
1887
+ // comes from a transport/scanner failure string, which can name the
1888
+ // endpoint it failed to reach — and a gateway's stdout is routinely
1889
+ // shipped to a log aggregator, so "ephemeral" is not a property the
1890
+ // console actually has.
1891
+ const detail = redactNotifyDetail(result.error ?? result.summary);
1892
+ console.warn(`[shieldcortex] ⚠️ conversation scan UNAVAILABLE (${detail}) — this message was NOT scanned`);
1893
+ // AWAITED. The whole function is already fire-and-forget from
1894
+ // `handleLlmInput`, so this blocks nothing the gateway is waiting on —
1895
+ // and it means the row is on disk before the loop moves to the next
1896
+ // message, and that `auditLog`'s new boolean (which logs loudly on
1897
+ // failure) is actually reached rather than discarded into a floating
1898
+ // promise.
1899
+ await auditLog({
1900
+ type: 'scan_unavailable', hook: 'llm_input', sessionId: event.sessionId,
1901
+ model: event.model, reason: detail,
1902
+ chars: text.length,
1903
+ contentSha256: createHash('sha256').update(text).digest('hex').slice(0, 16),
1904
+ ts: new Date().toISOString(),
1905
+ });
1906
+ continue;
1907
+ }
901
1908
  if (!result.clean) {
902
1909
  const trust = await resolveTrust();
903
1910
  console.warn(`[shieldcortex] ⚠️ Threat in LLM input: ${result.summary} [${trust.origin}]`);
@@ -907,7 +1914,7 @@ export async function scanLlmInput(event, _ctx) {
907
1914
  // the Action Guard tighten by one notch for a bounded window.
908
1915
  //
909
1916
  // Source trust gates the CONSEQUENCE, never the detection: the warn and
910
- // the audit row above happen whoever sent this. What trust decides is
1917
+ // the audit row below happen whoever sent this. What trust decides is
911
1918
  // whether it may tighten the guard. The operator typing "delete the old
912
1919
  // logs" is an instruction, and treating it as an attack is the false
913
1920
  // alarm that gets a control switched off. Everything the agent was
@@ -915,17 +1922,28 @@ export async function scanLlmInput(event, _ctx) {
915
1922
  if (trust.mayTaint) {
916
1923
  sessionTaint.mark(event.sessionId, { reason: `conversation scan: ${result.summary}` });
917
1924
  }
1925
+ // #226: NO `preview`. This row carried the first 100 characters of the
1926
+ // prompt — the exact text that tripped an injection detector, i.e.
1927
+ // hostile by assumption — into an append-only file that syncs. The gate
1928
+ // on the very next hook has recorded only `chars` + `contentSha256`
1929
+ // since #225 and says in its own comment that the prompt is never
1930
+ // persisted; the observation hook quietly did the opposite, so the
1931
+ // claim was false on the path that runs on every single turn. Length
1932
+ // plus digest keeps the row correlatable with the gate's row for the
1933
+ // same text without storing the text.
918
1934
  const entry = {
919
1935
  type: "threat", hook: "llm_input", sessionId: event.sessionId,
920
1936
  model: event.model, reason: result.summary,
921
- preview: text.slice(0, 100), ts: new Date().toISOString(),
1937
+ chars: text.length,
1938
+ contentSha256: createHash('sha256').update(text).digest('hex').slice(0, 16),
1939
+ ts: new Date().toISOString(),
922
1940
  };
923
- auditLog(entry);
1941
+ await auditLog(entry);
924
1942
  loadConfig()
925
1943
  // Pass the local entry as-is; cloudSync rebuilds a canonical metadata-only
926
- // entry from named fields and never reads preview/content. No raw LLM input
1944
+ // entry from named fields and never reads content. No raw LLM input
927
1945
  // leaves here.
928
- .then(cfg => cloudSync(entry, cfg))
1946
+ .then(cfg2 => cloudSync(entry, cfg2))
929
1947
  .catch(() => { });
930
1948
  }
931
1949
  }
@@ -935,9 +1953,625 @@ export async function scanLlmInput(event, _ctx) {
935
1953
  }
936
1954
  }
937
1955
  function handleLlmInput(event, ctx) {
938
- // Fire and forget
1956
+ // Fire and forget — OBSERVATION ONLY. This hook cannot block (#225); the
1957
+ // enforcement point is handleBeforeAgentRun below.
939
1958
  void scanLlmInput(event, ctx);
940
1959
  }
1960
+ /**
1961
+ * Route a conversation-threat detection to a HUMAN (#225).
1962
+ *
1963
+ * This is the "sink" the issue is named for. Before it existed, a HIGH verdict
1964
+ * produced a console line and an audit row, and a real detection on a live box
1965
+ * was seen by nobody. It reuses the Action Guard's notify transport (#143)
1966
+ * rather than inventing a second one — two notification paths would drift, and
1967
+ * the operator would learn which one to ignore.
1968
+ *
1969
+ * Returns whether a human was actually reached, so callers (and doctor) can
1970
+ * report "detected but undeliverable" instead of implying someone was told.
1971
+ * Never throws: a failed notification must not become a failed turn.
1972
+ */
1973
+ /** The longest the conversation gate will wait for an operator alert to be
1974
+ * handed to a transport. See the call site: the user's turn is blocked on this
1975
+ * hook, so the alert's deadline has to be a fraction of the hook's. */
1976
+ const CONVERSATION_NOTIFY_MAX_MS = 5_000;
1977
+ /**
1978
+ * The longest the conversation gate will wait for a SCAN (#226).
1979
+ *
1980
+ * `before_agent_run` is awaited by the gateway — the user's turn is stopped
1981
+ * dead until this handler returns — and the scan's fallback path is an MCP
1982
+ * shell-out that boots a cold server via `npx`, which can take upwards of 15s.
1983
+ * That is half the hook's entire 30s budget before an alert and two audit
1984
+ * writes are added behind it, and it happens on exactly the hosts where the
1985
+ * in-process defence module failed to load: the ones already degraded.
1986
+ *
1987
+ * Past this deadline the scan is treated as UNAVAILABLE, which fails OPEN (the
1988
+ * turn proceeds) and is audited and alerted like any other unavailable scan. A
1989
+ * security control that silently adds fifteen seconds to every prompt is one an
1990
+ * operator uninstalls.
1991
+ */
1992
+ export const CONVERSATION_SCAN_MAX_MS = 5_000;
1993
+ /**
1994
+ * Repeat-alert suppression for the scan-unavailable path (#226).
1995
+ *
1996
+ * An unavailable scanner is not a transient event: it is usually a missing
1997
+ * install, a broken defence build or an absent binary, and it recurs on EVERY
1998
+ * turn. Alerting per turn turns the operator's phone into a metronome and the
1999
+ * alert into something they mute — which is the same outcome as never sending
2000
+ * one, reached by a more expensive route. First occurrence goes immediately;
2001
+ * after that, at most one alert per window, with the suppressed count carried
2002
+ * on the next alert that does go out so nothing is lost.
2003
+ *
2004
+ * THE WINDOW IS PER SESSION, not per process — see `noteScanUnavailable`. A
2005
+ * gateway runs many sessions at once, and "we already told you about session A"
2006
+ * is not a reason to stay silent about session B.
2007
+ *
2008
+ * AUDITING IS NOT RATE LIMITED. Every occurrence still writes its row, and the
2009
+ * row records whether an alert was suppressed and how many have been seen —
2010
+ * the evidence trail must be complete even when the notification stream is not.
2011
+ */
2012
+ export const SCAN_UNAVAILABLE_ALERT_WINDOW_MS = 5 * 60_000;
2013
+ /**
2014
+ * The bucket used when the host hands us no session identity at all.
2015
+ *
2016
+ * `before_agent_run`'s context declares `sessionId` and `sessionKey` as
2017
+ * OPTIONAL, so both can be absent. Keying such an occurrence under a fixed
2018
+ * fallback keeps rate limiting working exactly as it did on a single-session
2019
+ * host, and — critically — keeps a nameless occurrence from sharing a bucket
2020
+ * with a NAMED one, which is what a `String(undefined)` key would have done.
2021
+ */
2022
+ const SCAN_UNAVAILABLE_FALLBACK_SESSION = '__unkeyed-session__';
2023
+ /**
2024
+ * Cap on distinct sessions tracked at once.
2025
+ *
2026
+ * `session_end` is what normally frees an entry, and it is not guaranteed: an
2027
+ * older host may not emit it, and a crashed session never will. This map is
2028
+ * therefore bounded and evicts least-recently-seen first. Overshooting the cap
2029
+ * costs at most one extra alert for the evicted session — the safe direction,
2030
+ * since the failure mode of eviction is "tell the operator again", not "stay
2031
+ * quiet". Each entry is four numbers and a short key, so 512 of them is a few
2032
+ * kilobytes in a process that already holds a scanner.
2033
+ */
2034
+ export const SCAN_UNAVAILABLE_MAX_SESSIONS = 512;
2035
+ const _scanUnavailable = new Map();
2036
+ /** Normalise whatever the host gave us into a map key. */
2037
+ function scanUnavailableSessionKey(sessionKey) {
2038
+ const trimmed = typeof sessionKey === 'string' ? sessionKey.trim() : '';
2039
+ return trimmed === '' ? SCAN_UNAVAILABLE_FALLBACK_SESSION : trimmed;
2040
+ }
2041
+ /**
2042
+ * Should this scan-unavailable occurrence raise an operator alert?
2043
+ *
2044
+ * PER SESSION (#226). The first cut kept one module-global counter, which on a
2045
+ * gateway — a process that multiplexes every channel and every concurrent
2046
+ * agent — meant one session's broken scanner silenced the FIRST failure of
2047
+ * every other session for the next five minutes. That is the same class of bug
2048
+ * the rate limit exists to avoid, inverted: instead of too many alerts, a real
2049
+ * new failure is never reported at all. Suppression is a property of one
2050
+ * session's repeating failure, so the state is keyed by one session.
2051
+ *
2052
+ * Pure apart from the per-session counter it advances, and driven by an
2053
+ * injectable `now` so the window is testable without sleeping. Exported for the
2054
+ * regression test; not part of the plugin's host-facing surface.
2055
+ *
2056
+ * The key is a session id, never logged: it reaches this function only to index
2057
+ * the map. The audit rows that carry `sessionId` are the deliberate place that
2058
+ * fact is recorded.
2059
+ */
2060
+ export function noteScanUnavailable(sessionKey, nowMs = Date.now()) {
2061
+ const key = scanUnavailableSessionKey(sessionKey);
2062
+ let state = _scanUnavailable.get(key);
2063
+ if (!state) {
2064
+ evictScanUnavailableOverflow(nowMs);
2065
+ state = { count: 0, lastAlertAtMs: null, suppressedSinceAlert: 0, lastSeenAtMs: nowMs };
2066
+ _scanUnavailable.set(key, state);
2067
+ }
2068
+ state.count += 1;
2069
+ state.lastSeenAtMs = nowMs;
2070
+ const last = state.lastAlertAtMs;
2071
+ // A clock that jumped BACKWARDS (NTP step, suspend/resume) must not be able
2072
+ // to wedge alerting off forever: treat a negative elapsed as "window over".
2073
+ const elapsed = last === null ? Infinity : nowMs - last;
2074
+ if (last === null || elapsed >= SCAN_UNAVAILABLE_ALERT_WINDOW_MS || elapsed < 0) {
2075
+ const suppressed = state.suppressedSinceAlert;
2076
+ state.lastAlertAtMs = nowMs;
2077
+ state.suppressedSinceAlert = 0;
2078
+ return { alert: true, count: state.count, suppressedSinceLastAlert: suppressed };
2079
+ }
2080
+ state.suppressedSinceAlert += 1;
2081
+ return {
2082
+ alert: false,
2083
+ count: state.count,
2084
+ suppressedSinceLastAlert: state.suppressedSinceAlert,
2085
+ };
2086
+ }
2087
+ /** Keep the session map bounded when `session_end` never arrives. Evicts the
2088
+ * least-recently-seen entries; an evicted session simply alerts once more. */
2089
+ function evictScanUnavailableOverflow(nowMs) {
2090
+ if (_scanUnavailable.size < SCAN_UNAVAILABLE_MAX_SESSIONS)
2091
+ return;
2092
+ const oldestFirst = [..._scanUnavailable.entries()].sort((a, b) => (a[1].lastSeenAtMs ?? nowMs) - (b[1].lastSeenAtMs ?? nowMs));
2093
+ const drop = _scanUnavailable.size - SCAN_UNAVAILABLE_MAX_SESSIONS + 1;
2094
+ for (const [key] of oldestFirst.slice(0, drop))
2095
+ _scanUnavailable.delete(key);
2096
+ }
2097
+ /**
2098
+ * Forget ONE session's suppression window. Called from `session_end`, so a
2099
+ * long-lived gateway does not carry a finished session's suppression into a
2100
+ * reused id — and, just as importantly, does not clear anyone ELSE's.
2101
+ *
2102
+ * A `session_end` that names no session clears the fallback bucket only: on a
2103
+ * host that supplies no session identity every occurrence lands there, so that
2104
+ * is precisely the state that ended.
2105
+ */
2106
+ export function resetScanUnavailableAlertState(sessionKey) {
2107
+ _scanUnavailable.delete(scanUnavailableSessionKey(sessionKey));
2108
+ }
2109
+ /** Test/reset seam: forget EVERY session. Production never wants this — one
2110
+ * session ending must not re-arm alerting for the others — so it is reachable
2111
+ * only from `__resetConfigStateForTest`. */
2112
+ export function __resetScanUnavailableAlertState() {
2113
+ _scanUnavailable.clear();
2114
+ }
2115
+ /**
2116
+ * Make a failure detail safe to PERSIST, SEND or PRINT (#226).
2117
+ *
2118
+ * The detail is assembled from channel names, scanner errors and whatever a
2119
+ * transport said went wrong. Node's fetch failures do not name the URL, but a
2120
+ * transport is free to put one in its reason — and a notify webhook URL
2121
+ * routinely carries a token in its path or query
2122
+ * (`https://hooks.example/services/T0/B0/XXXXXXXX`). So any http(s) URL is
2123
+ * reduced to its origin: enough to tell WHICH endpoint failed, not enough to
2124
+ * replay a request to it. Bounded too, so a transport that returns a page of
2125
+ * HTML cannot bloat the log.
2126
+ *
2127
+ * EVERY sink gets the redacted string — not just the ones that obviously
2128
+ * outlive the process. The audit row is append-only and syncs; the notification
2129
+ * leaves the box; and the console is NOT the ephemeral thing an earlier version
2130
+ * of this comment claimed it was, because a gateway's stdout is routinely
2131
+ * shipped to a log aggregator and kept longer than the audit file. Redacting
2132
+ * for the row and not for the other two protected the least exposed of the
2133
+ * three.
2134
+ */
2135
+ export function redactNotifyDetail(detail) {
2136
+ const withoutUrls = String(detail ?? '').replace(/https?:\/\/[^\s'"]+/gi, (url) => {
2137
+ try {
2138
+ return `${new URL(url).origin}/…`;
2139
+ }
2140
+ catch {
2141
+ return '<url>';
2142
+ }
2143
+ });
2144
+ return withoutUrls.length > 500 ? `${withoutUrls.slice(0, 499)}…` : withoutUrls;
2145
+ }
2146
+ /**
2147
+ * The seam a gateway MIGHT offer for sending an operator a message, captured at
2148
+ * register() time if the API exposes it.
2149
+ *
2150
+ * No OpenClaw build we have inspected exposes it — neither 2026.5.2 nor
2151
+ * 2026.7.1 has a `notifyOperator` anywhere in its plugin API — so in practice
2152
+ * the webhook is the load-bearing channel and this stays null. It is read
2153
+ * structurally rather than removed because #143's design intent was that on
2154
+ * OpenClaw the transport should use the gateway's own message capability, and
2155
+ * that only becomes true if the code is ready for the day it appears. Nothing
2156
+ * here should be read as "ShieldCortex delivers natively on OpenClaw today".
2157
+ */
2158
+ let _gatewayNotifyContext = null;
2159
+ export function __setGatewayNotifyContextForTest(ctx) {
2160
+ _gatewayNotifyContext = ctx;
2161
+ }
2162
+ /**
2163
+ * Route a conversation-firewall detection to a HUMAN (#225).
2164
+ *
2165
+ * This is the "sink" the issue is named for: before it existed, a HIGH verdict
2166
+ * produced a console line and an audit row, and a real detection on a live box
2167
+ * was seen by nobody.
2168
+ *
2169
+ * It reuses the Action Guard's #143 transport rather than inventing a second
2170
+ * one — but *correctly*, which the first cut did not:
2171
+ *
2172
+ * - the notification is built by the main package's
2173
+ * `buildConversationThreatNotification`, so it is a real, bounded
2174
+ * notification with its own event discriminator, NOT an ad-hoc
2175
+ * `{kind, severity, …}` literal cast through `NotifyChannel.send`. A
2176
+ * conversation alert therefore cannot render Approve/Deny controls or a
2177
+ * hash that does not exist — the fields simply are not on the type.
2178
+ * - delivery goes through `deliverOperatorNotification`, the same core the
2179
+ * approval path uses, so the deadline, the malformed-result handling and
2180
+ * the "nothing but the boolean is read back" rule are shared, not copied.
2181
+ * - the webhook secret is read from `webhookSecret` — the field
2182
+ * `normaliseNotifyConfig` actually returns. Mirroring it as `secret`
2183
+ * silently produced UNSIGNED POSTs.
2184
+ * - both channels are offered where the runtime provides them: the gateway's
2185
+ * own message seam first WHERE IT EXISTS (no build we have inspected
2186
+ * exposes one — see `_gatewayNotifyContext`), then the configured webhook,
2187
+ * which is what actually carries an alert off the box today.
2188
+ *
2189
+ * Returns what happened, and NEVER throws: a failed notification must not
2190
+ * become a failed turn.
2191
+ */
2192
+ export async function notifyOperatorOfConversationThreat(input) {
2193
+ try {
2194
+ const mod = await getDefenceModule();
2195
+ const cfg = await loadConfig();
2196
+ const raw = cfg.interceptor?.actionGuard?.notify;
2197
+ if (!raw)
2198
+ return { configured: false, delivered: false, via: null, detail: 'no notify config' };
2199
+ if (typeof mod?.normaliseNotifyConfig !== 'function') {
2200
+ return { configured: true, delivered: false, via: null, detail: 'installed shieldcortex build has no notify transport' };
2201
+ }
2202
+ const notify = mod.normaliseNotifyConfig(raw);
2203
+ if (!notify.enabled)
2204
+ return { configured: false, delivered: false, via: null, detail: 'notify disabled' };
2205
+ const channels = [];
2206
+ // The gateway's own message seam, WHERE the runtime provides one. It would
2207
+ // go first, because it would reach the operator on a channel they already
2208
+ // read — but `_gatewayNotifyContext` is null on every build we have
2209
+ // inspected, so in practice this list starts at the webhook below.
2210
+ if (notify.openclaw === true && _gatewayNotifyContext) {
2211
+ const gatewayChannel = createGatewayNotifyChannel(_gatewayNotifyContext);
2212
+ if (gatewayChannel)
2213
+ channels.push(gatewayChannel);
2214
+ }
2215
+ if (notify.webhookUrl && typeof mod.createWebhookNotifyChannel === 'function') {
2216
+ channels.push(mod.createWebhookNotifyChannel({
2217
+ url: notify.webhookUrl,
2218
+ // The signing key. Passed straight through and never logged — see
2219
+ // notify-config.ts, which is the only place this value is parsed.
2220
+ secret: notify.webhookSecret,
2221
+ }));
2222
+ }
2223
+ if (channels.length === 0) {
2224
+ return { configured: true, delivered: false, via: null, detail: 'notify enabled but no channel is configured/buildable on this host' };
2225
+ }
2226
+ const notification = typeof mod.buildConversationThreatNotification === 'function'
2227
+ ? mod.buildConversationThreatNotification({
2228
+ outcome: input.outcome,
2229
+ posture: input.posture,
2230
+ summary: input.summary,
2231
+ reason: input.reason,
2232
+ sessionId: input.sessionId,
2233
+ model: input.model,
2234
+ host: hostname(),
2235
+ detectedAt: new Date().toISOString(),
2236
+ })
2237
+ : null;
2238
+ if (!notification) {
2239
+ // An older dist has the transport but not this event. Sending the
2240
+ // approval-shaped payload instead would put an Approve button on an alert
2241
+ // with nothing behind it — refuse, and say why.
2242
+ return {
2243
+ configured: true,
2244
+ delivered: false,
2245
+ via: null,
2246
+ detail: 'installed shieldcortex build predates the conversation-threat notification — refusing to send an approval-shaped alert',
2247
+ };
2248
+ }
2249
+ if (typeof mod.deliverOperatorNotification !== 'function') {
2250
+ return { configured: true, delivered: false, via: null, detail: 'installed shieldcortex build has no notification delivery core' };
2251
+ }
2252
+ const result = await mod.deliverOperatorNotification(notification, {
2253
+ channels,
2254
+ // Bounded HARDER than the transport's own configured deadline, because
2255
+ // this call sits inside a gate the gateway awaits: the user's turn is
2256
+ // waiting on it. The hook is registered with a 30s timeout, and a gate
2257
+ // that exceeds its own timeout is a security control that fails in a way
2258
+ // nobody has reasoned about. The alert is already in the log and the
2259
+ // audit row by this point, so what a longer wait buys is one extra retry
2260
+ // window on a transport that is, by then, visibly unhealthy.
2261
+ timeoutMs: Math.min(notify.timeoutMs ?? CONVERSATION_NOTIFY_MAX_MS, CONVERSATION_NOTIFY_MAX_MS),
2262
+ });
2263
+ const failures = result.attempts
2264
+ .filter((a) => !a.result.delivered)
2265
+ .map((a) => `${a.channel}: ${a.result.reason ?? 'failed'}`)
2266
+ .join('; ');
2267
+ return {
2268
+ configured: true,
2269
+ delivered: result.deliveredVia !== null,
2270
+ via: result.deliveredVia,
2271
+ detail: result.deliveredVia ? `delivered via ${result.deliveredVia}` : `undeliverable — ${failures || 'no channel accepted it'}`,
2272
+ };
2273
+ }
2274
+ catch (err) {
2275
+ return {
2276
+ configured: true,
2277
+ delivered: false,
2278
+ via: null,
2279
+ detail: `notify error: ${err instanceof Error ? err.message : String(err)}`,
2280
+ };
2281
+ }
2282
+ }
2283
+ /**
2284
+ * The gate's allow answer, stated explicitly (#226).
2285
+ *
2286
+ * A fresh literal per call, not a shared constant: the runner passes whatever
2287
+ * we return into its own merge/normalise chain, and a frozen singleton handed
2288
+ * to a host that decides to annotate it would fail in a way this plugin cannot
2289
+ * see. It costs one object per turn.
2290
+ *
2291
+ * WHY EXPLICIT, when the SDK types the result `InputGateDecision | void` and
2292
+ * this handler previously returned `undefined` on every allow path:
2293
+ *
2294
+ * The 2026.7.1-2 runner contradicts itself about void, one guard deep.
2295
+ * `runBeforeAgentRun`'s doc comment says "Handlers that return void are treated
2296
+ * as pass", and its `mergeResults` body opens with
2297
+ *
2298
+ * if (next === void 0 || next === null) → { outcome: "block",
2299
+ * reason: "…invalid decision" }
2300
+ *
2301
+ * i.e. the merge is written to BLOCK on void. What saves an `undefined` return
2302
+ * today is only that `runModifyingHook` never calls the merge for it —
2303
+ * `if (handlerResult !== void 0 && (handlerResult !== null || mergeNullResults))`
2304
+ * — so the void branch inside the merge is unreachable dead code, while `null`,
2305
+ * the sibling value that same line treats identically, reaches it and DOES
2306
+ * block. Verified by executing the real 2026.7.1-2 runner: `undefined` → pass,
2307
+ * `null` → block/invalid, `{outcome:'pass'}` → pass.
2308
+ *
2309
+ * So void is not broken here — it is correct by one guard, against a merge
2310
+ * function whose stated intent is to reject it. `{ outcome: 'pass' }` is
2311
+ * correct under BOTH readings, and is the shape the host validates rather than
2312
+ * the shape it happens to skip. That is the difference worth having in front of
2313
+ * every user turn.
2314
+ */
2315
+ function gatePass() {
2316
+ return { outcome: 'pass' };
2317
+ }
2318
+ /**
2319
+ * The conversation firewall's enforcement point (#225).
2320
+ *
2321
+ * Unlike `llm_input`, this hook is awaited by the gateway and its return value
2322
+ * decides whether the run proceeds. It scans the prompt, applies the configured
2323
+ * posture, and — critically — routes a detection to a HUMAN rather than only to
2324
+ * a log file. The finding this fixes was that a HIGH verdict on a live box was
2325
+ * seen by nobody.
2326
+ *
2327
+ * Fails OPEN on any internal error: a security plugin that bricks the gateway
2328
+ * has caused a worse outage than the one it prevents. Every failure is reported.
2329
+ *
2330
+ * EVERY path returns a decision — `gatePass()` to allow, `{ outcome: 'block' }`
2331
+ * only for a dirty verdict under `enforce`. Nothing returns `undefined`; see
2332
+ * `gatePass` for the host-contract reason. "Fails open" therefore now means an
2333
+ * explicit pass, which is a stronger statement than the absence of an answer:
2334
+ * it is the same word said in the vocabulary the host validates.
2335
+ */
2336
+ export async function handleBeforeAgentRun(event, ctx) {
2337
+ let posture = 'observe';
2338
+ try {
2339
+ const cfg = await loadConfig();
2340
+ posture = conversationPosture(cfg.interceptor?.conversation);
2341
+ if (posture === 'off')
2342
+ return gatePass();
2343
+ const text = String(event?.prompt ?? '');
2344
+ if (!text || text.length < 10 || isInternalContent(text))
2345
+ return gatePass();
2346
+ // sessionId/model come off the hook CONTEXT (PluginHookAgentContext); the
2347
+ // event carries neither. Both are optional there too, so both may be absent.
2348
+ const sessionId = ctx?.sessionId ?? ctx?.sessionKey;
2349
+ const model = ctx?.modelId;
2350
+ // scanRealtimeContent no longer throws on the paths that used to (it
2351
+ // reports `available:false` instead), but a defensive catch stays: this
2352
+ // function's contract is that nothing here can stop a turn by accident.
2353
+ // #226: BOUNDED. The gateway awaits this hook, so an unbounded scan is an
2354
+ // unbounded pause in front of the user's prompt — see scanWithDeadline.
2355
+ let scan;
2356
+ try {
2357
+ scan = await scanWithDeadline(text);
2358
+ }
2359
+ catch (err) {
2360
+ const detail = err instanceof Error ? err.message : String(err);
2361
+ scan = { clean: false, available: false, errored: true, error: detail, summary: 'scan unavailable' };
2362
+ }
2363
+ // #235: WHO sent this turn, resolved before the verdict is applied.
2364
+ // `senderIsOwner` was declared on the event and read by nothing, so the
2365
+ // enforce path could block the operator's own paste and destroy it. The
2366
+ // config is already loaded, so this is a pure call — no second read.
2367
+ const trust = classifyConversationOrigin({
2368
+ senderIsOwner: event?.senderIsOwner,
2369
+ trustOwnerInput: cfg.conversationTrust?.trustOwnerInput,
2370
+ });
2371
+ const decision = evaluateConversationRun(posture, scan, trust);
2372
+ // #226: REDACT ONCE, then use the redacted string everywhere the reason
2373
+ // goes — the persisted decision row, the outbound notification, the block
2374
+ // reason, the console line. On the unavailable path `decision.reason`
2375
+ // embeds the scanner's own failure string verbatim
2376
+ // (`conversation scan unavailable (${scan.error})`), and that string is
2377
+ // assembled from a transport error: a cold MCP start, a fetch, a defence
2378
+ // build download. Any of those can name the endpoint it failed to reach,
2379
+ // and such a URL routinely carries a credential in its path
2380
+ // (`https://hooks.example/services/T0/B0/XXXX`). The console line was
2381
+ // already redacted while the row and the alert — the two that PERSIST and
2382
+ // LEAVE THE BOX — were not, which had the guarantee exactly backwards.
2383
+ const safeReason = decision.reason === null ? null : redactNotifyDetail(decision.reason);
2384
+ // #226: repeated unavailability alerts at most once per window. Called here
2385
+ // rather than at the notify site so the COUNTERS advance on every
2386
+ // occurrence, and the decision row can record what was suppressed even when
2387
+ // no alert goes out.
2388
+ // Keyed by SESSION: a broken scanner in one session must not silence the
2389
+ // first report of a broken scanner in another. `sessionId` may be absent —
2390
+ // noteScanUnavailable buckets that case separately rather than letting one
2391
+ // nameless session stand in for all of them.
2392
+ const unavailable = decision.outcome === 'unavailable';
2393
+ const alertGate = unavailable ? noteScanUnavailable(sessionId) : null;
2394
+ const suppressAlert = alertGate !== null && !alertGate.alert;
2395
+ // ── EVIDENCE FIRST, SIDE EFFECT SECOND ────────────────────────────────
2396
+ //
2397
+ // The decision row is a LOCAL append and it goes to disk before anything
2398
+ // leaves this box. The previous order awaited an external notification and
2399
+ // then wrote the row — under a comment claiming the row already existed —
2400
+ // so every way that call can end badly took the evidence with it: a
2401
+ // notification channel that hangs until the gateway's 30s hook timeout
2402
+ // fires, a transport that throws past its own catch, an operator restarting
2403
+ // the gateway mid-alert, the process dying. In each case the block or the
2404
+ // detection HAPPENED and there is no record that it did. That inverts the
2405
+ // whole point: a security control's own log must not be contingent on an
2406
+ // unrelated network round trip succeeding.
2407
+ //
2408
+ // The row carries a stable `eventId`, so the delivery row appended after
2409
+ // the attempt below can be joined to it without either row having to
2410
+ // predict the other's outcome.
2411
+ const eventId = randomUUID();
2412
+ // #226: whether the decision row ACTUALLY LANDED. `auditLog` used to
2413
+ // swallow its failures and return void, so the code below could not tell an
2414
+ // append from a silent no-op and every downstream statement — the operator
2415
+ // alert, the delivery row, this function's own comments — asserted that
2416
+ // evidence existed. Now the boolean is carried, said out loud on stderr,
2417
+ // and attached to the alert as a bounded, secret-free fact.
2418
+ let decisionRowPersisted = true;
2419
+ if (decision.audit) {
2420
+ // AWAITED: the row that says what was decided must exist before the
2421
+ // decision is handed back, and its success or failure must be READ. The
2422
+ // write is a bounded local append wrapped in its own try/catch.
2423
+ decisionRowPersisted = await auditLog({
2424
+ type: decision.outcome === 'unavailable' ? 'scan_unavailable' : 'threat',
2425
+ hook: 'before_agent_run',
2426
+ eventId,
2427
+ sessionId,
2428
+ model,
2429
+ // The REDACTED reason. This row is appended to a file that syncs.
2430
+ reason: safeReason,
2431
+ posture,
2432
+ outcome: decision.outcome,
2433
+ // #235: the origin, on every conversation decision row. Without it an
2434
+ // operator auditing an `enforce` host cannot tell a turn that was not
2435
+ // blocked because it was clean from one that was not blocked because
2436
+ // the owner sent it — and "why did this not block?" is the question
2437
+ // this row exists to answer. A label ('owner'/'non-owner'/'unknown'),
2438
+ // never a sender id: the row syncs.
2439
+ origin: trust.origin,
2440
+ // The verdict summary, never the prompt. The input that trips an
2441
+ // injection detector is hostile text by assumption; copying it into an
2442
+ // audit row that syncs to the dashboard/cloud would carry the payload
2443
+ // one hop further. A length + digest keeps rows correlatable without
2444
+ // storing the content.
2445
+ verdict: scan.summary,
2446
+ chars: text.length,
2447
+ contentSha256: createHash('sha256').update(text).digest('hex').slice(0, 16),
2448
+ // Deliberately NOT `notified: false`. Nothing has been attempted yet,
2449
+ // and a false here would read as "we tried and failed". The attempt's
2450
+ // result is its own row, keyed by this eventId.
2451
+ notifyPending: decision.notify && !suppressAlert,
2452
+ // #226: the unavailability run-length, on EVERY occurrence. Alerting is
2453
+ // rate limited; auditing is not, so the row is where the true count
2454
+ // lives — and it says explicitly when an alert was withheld, so a gap in
2455
+ // the alert stream can never be mistaken for a gap in the failures.
2456
+ ...(alertGate
2457
+ ? {
2458
+ unavailableCount: alertGate.count,
2459
+ alertSuppressed: suppressAlert,
2460
+ alertSuppressedSinceLastAlert: alertGate.suppressedSinceLastAlert,
2461
+ }
2462
+ : {}),
2463
+ ts: new Date().toISOString(),
2464
+ });
2465
+ if (!decisionRowPersisted) {
2466
+ console.error(`[shieldcortex] ⚠️ conversation ${decision.outcome} decision could NOT be written to the audit log — ` +
2467
+ 'the decision itself still stands, but there is no local record of it. Check the audit directory ' +
2468
+ '(SHIELDCORTEX_AUDIT_DIR or ~/.shieldcortex/audit) for permissions or disk space.');
2469
+ }
2470
+ }
2471
+ // The sink. Awaited — the first cut fired this off with `void` and threw
2472
+ // the delivery boolean away, so the code could not tell "a human was told"
2473
+ // from "nothing left this box". It is bounded (CONVERSATION_NOTIFY_MAX_MS,
2474
+ // well under the hook's own 30s timeout) and never throws.
2475
+ let notifyResult = null;
2476
+ if (decision.notify) {
2477
+ const label = decision.outcome === 'unavailable' ? 'unavailable' : decision.block ? 'blocked' : 'observed';
2478
+ // The same redacted string the row got. A gateway's stdout is routinely
2479
+ // shipped to a log aggregator, so "ephemeral" is not a property the
2480
+ // console actually has either.
2481
+ console.warn(`[shieldcortex] ⚠️ ${safeReason ?? 'conversation threat'} — posture=${posture}, outcome=${label}` +
2482
+ (suppressAlert
2483
+ ? ` (operator alert SUPPRESSED — ${alertGate?.suppressedSinceLastAlert} since the last one; ${alertGate?.count} this session)`
2484
+ : ''));
2485
+ // Audited above, not alerted. Nothing further is written for a suppressed
2486
+ // occurrence: no attempt was made, and a delivery row saying
2487
+ // `delivered: false` would read as a transport failure that never
2488
+ // happened. The decision itself is unaffected — suppression governs who
2489
+ // is TOLD, never what is DECIDED.
2490
+ if (!suppressAlert) {
2491
+ // The audit-persistence fact rides ALONG with the alert when the local
2492
+ // record failed: bounded, no secrets, and it tells the operator that
2493
+ // this notification is the only trace of the event. Appended to the
2494
+ // reason rather than added as a field so it survives an older installed
2495
+ // dist whose notification builder does not know about it.
2496
+ const auditNote = decisionRowPersisted ? '' : ' [auditPersistence=failed: no local audit row for this event]';
2497
+ const suppressedNote = alertGate && alertGate.suppressedSinceLastAlert > 0
2498
+ ? ` [${alertGate.suppressedSinceLastAlert} further scan-unavailable event(s) suppressed since the last alert; ${alertGate.count} this session]`
2499
+ : '';
2500
+ notifyResult = await notifyOperatorOfConversationThreat({
2501
+ outcome: label,
2502
+ posture,
2503
+ summary: scan.summary,
2504
+ // REDACTED. This one leaves the box entirely — to a webhook, an
2505
+ // aggregator, a phone — so it is the last place a tokenised endpoint
2506
+ // URL lifted out of a scanner error may appear.
2507
+ reason: `${safeReason ?? 'conversation threat'}${suppressedNote}${auditNote}`,
2508
+ sessionId,
2509
+ model,
2510
+ });
2511
+ // Truthful reporting: never imply a human was reached unless a
2512
+ // transport said so. "Not configured" is not a failure — it is the #143
2513
+ // default.
2514
+ if (notifyResult.configured && !notifyResult.delivered) {
2515
+ // #226: redacted HERE too, not only on the row below. The same detail
2516
+ // string reaches both, and a gateway's stdout is routinely shipped
2517
+ // somewhere it outlives the process.
2518
+ console.warn(`[shieldcortex] ⚠️ conversation alert UNDELIVERED — ${redactNotifyDetail(notifyResult.detail)}`);
2519
+ }
2520
+ // Guarded on `audit` because `eventId` has to point at something:
2521
+ // `evaluateConversationRun` never sets notify without audit, and if that
2522
+ // ever changed, a delivery row keyed to a decision row that was never
2523
+ // written would be a dangling reference rather than evidence.
2524
+ if (decision.audit) {
2525
+ // A SECOND row, not a rewrite of the first. The audit sink is an
2526
+ // append-only JSONL file, so "what was decided" and "who was told" are
2527
+ // separate facts recorded when each became true, joined by eventId.
2528
+ // `via` is the channel NAME ('webhook', 'openclaw-gateway'), never a
2529
+ // URL; the detail is redacted before it is persisted.
2530
+ await auditLog({
2531
+ type: 'notification_delivery',
2532
+ hook: 'before_agent_run',
2533
+ eventId,
2534
+ sessionId,
2535
+ configured: notifyResult.configured,
2536
+ delivered: notifyResult.delivered,
2537
+ via: notifyResult.via,
2538
+ detail: redactNotifyDetail(notifyResult.detail),
2539
+ // The eventId this row joins on may point at a row that was never
2540
+ // written. Say so here rather than leave a dangling reference that
2541
+ // reads as a missing file rather than a failed write.
2542
+ ...(decisionRowPersisted ? {} : { auditPersistence: 'failed' }),
2543
+ ts: new Date().toISOString(),
2544
+ });
2545
+ }
2546
+ }
2547
+ }
2548
+ // Clean, observed-not-blocked, and scan-unavailable all land here. The
2549
+ // audit row and the operator alert above have already recorded what
2550
+ // happened; the run itself proceeds, and says so.
2551
+ if (!decision.block)
2552
+ return gatePass();
2553
+ return {
2554
+ outcome: 'block',
2555
+ // Redacted for the same reason as the row above: the host SDK documents
2556
+ // `reason` as internal, but "internal" is a policy, not a guarantee.
2557
+ reason: safeReason ?? 'conversation threat',
2558
+ message: `ShieldCortex blocked this turn: ${scan.summary}. The prompt was not sent to the model.`,
2559
+ category: 'prompt_injection',
2560
+ };
2561
+ }
2562
+ catch (e) {
2563
+ // Fail open, loudly. Never let the guard's own failure stop the agent.
2564
+ //
2565
+ // This catch is also why the handler must not be allowed to THROW: the host
2566
+ // registers `before_agent_run` as fail-CLOSED
2567
+ // (`failurePolicyByHook: { before_agent_run: 'fail-closed' }`), so an
2568
+ // exception escaping here does not fail open at all — the gateway catches it
2569
+ // and blocks the run with "before_agent_run hook failed". An explicit pass
2570
+ // is the only way this function actually keeps its fail-open promise.
2571
+ console.error('[shieldcortex] before_agent_run error (failing open):', e instanceof Error ? e.message : String(e));
2572
+ return gatePass();
2573
+ }
2574
+ }
941
2575
  // Skip text blocks that are ShieldCortex/OpenClaw tool-result pass-throughs
942
2576
  function isToolResultContent(text) {
943
2577
  // ShieldCortex recall returns "Found N memories:" header
@@ -1117,6 +2751,12 @@ export default {
1117
2751
  if (_registered)
1118
2752
  return;
1119
2753
  _registered = true;
2754
+ // #226: the host runtime's own version, before anything can throw. It is
2755
+ // the primary version evidence for the conversation-gate check — the
2756
+ // gateway stating its own version beats inferring one from whichever
2757
+ // package.json sits above the entry path. Absent on a host that does not
2758
+ // expose it, which stays UNKNOWN rather than becoming a guess.
2759
+ recordHostRuntimeVersion(api);
1120
2760
  // --- Interceptor (lazy init) ---
1121
2761
  let interceptorReady = null;
1122
2762
  let interceptorInitAttempted = false;
@@ -1163,11 +2803,43 @@ export default {
1163
2803
  : `${guardCfg.enforce ? "enforce" : "warn"}${autoApproved > 0 ? ` (${autoApproved} auto-approved)` : ""}${interceptorReady ? "" : " — not yet initialised this session"}`;
1164
2804
  const hooksLine = _beforeToolCallRegistered
1165
2805
  ? "llm_input (scan), llm_output (memory), before_tool_call (action guard), session_end (cache reset)"
1166
- : "llm_input (scan), llm_output (memory)";
2806
+ // #226: session_end is registered even with the interceptor off —
2807
+ // the conversation gate keeps per-session state that needs freeing.
2808
+ : "llm_input (scan), llm_output (memory), session_end (cache reset)";
2809
+ // #225: the conversation plane, stated as evidence rather than as a
2810
+ // tick. Every clause below is something this process actually knows:
2811
+ // the configured posture, that we asked for the hook, the host build,
2812
+ // and the operator's grant. Nothing here claims the gateway accepted
2813
+ // the registration, because the plugin API never says so.
2814
+ const hostProbe = detectHostOpenClaw();
2815
+ const plane = describeConversationPlane({
2816
+ posture: conversationPosture(cfg.interceptor?.conversation),
2817
+ hookRequested: _beforeAgentRunRequested,
2818
+ gateSupport: hostSupportsConversationGate(hostProbe),
2819
+ hostOpenClawVersion: hostProbe.version,
2820
+ consentGranted: _conversationAccessGranted,
2821
+ });
2822
+ const notifyRaw = cfg.interceptor?.actionGuard?.notify;
2823
+ const notifyState = notifyRaw && notifyRaw.enabled === true
2824
+ ? 'configured'
2825
+ : 'not configured — detections reach the audit log and this box only';
1167
2826
  return {
1168
2827
  text: `ShieldCortex v${_version}\n` +
1169
- ` Hooks: ${hooksLine}\n` +
2828
+ ` Hooks: ${hooksLine}${_beforeAgentRunRequested ? ', before_agent_run (conversation gate, requested)' : ''}\n` +
1170
2829
  ` Action guard: ${guardState}\n` +
2830
+ ` Conversation firewall: ${plane.summary}\n` +
2831
+ // #226: state the PROVENANCE, not just the value. This flag is a
2832
+ // SNAPSHOT taken once, when the plugin loaded — the host reads
2833
+ // the grant at hook-registration time and this process never
2834
+ // re-reads it. So an operator who has just edited openclaw.json
2835
+ // and re-run the command sees the old answer, correctly, and
2836
+ // would otherwise conclude the grant does not work. Nothing here
2837
+ // is live: changing it requires a gateway restart before either
2838
+ // the gateway or this line reflects it.
2839
+ ` Conversation access grant: ${_conversationAccessGranted ? 'granted' : 'NOT granted'} (plugins.entries.${PLUGIN_ID}.hooks.allowConversationAccess)\n` +
2840
+ ' — read from openclaw.json when this plugin LOADED; it is a snapshot, not a live read.\n' +
2841
+ ' Editing that key takes effect only after a gateway restart, for the gateway and for this line.\n' +
2842
+ ` Operator notify: ${notifyState}\n` +
1171
2843
  ` Auto memory: ${autoMemory} | Dedupe: ${dedupe}\n` +
1172
2844
  ` Cloud sync: ${cloud}`,
1173
2845
  };
@@ -1269,41 +2941,148 @@ export default {
1269
2941
  return handleTypedBeforeToolCall(event, interceptor, api.logger, ctx?.sessionId);
1270
2942
  }, { priority: 80, timeoutMs: 30_000 });
1271
2943
  _beforeToolCallRegistered = true;
1272
- // Try to register session_end for cache cleanup (only meaningful while
1273
- // an interceptor can exist)
1274
- try {
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
- });
1281
- }
1282
- catch {
1283
- // session_end may not be a supported hook — TTL safety net handles this
1284
- }
2944
+ // NOTE: session_end is NOT registered here — it moved out of this guard
2945
+ // in #226 and is registered unconditionally below.
1285
2946
  }
1286
2947
  else {
1287
2948
  api.logger?.info?.('[shieldcortex] interceptor.enabled:false in plugin config — before_tool_call hook not registered');
1288
2949
  }
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).
2950
+ // session_end — registered UNCONDITIONALLY (#226).
2951
+ //
2952
+ // It used to live inside the `interceptorDisabledInHostConfig` guard above,
2953
+ // on the reasoning that it exists for the interceptor's session cache. That
2954
+ // stopped being true when `before_agent_run` landed: the gate is registered
2955
+ // regardless of `interceptor.enabled` (its posture, not the interceptor
2956
+ // flag, decides what it does), and it accumulates per-session
2957
+ // scan-unavailable suppression state. With the cleanup hook skipped, a host
2958
+ // that disabled the interceptor kept every session's window alive for the
2959
+ // life of the gateway process.
2960
+ //
2961
+ // Registering it does NOT reintroduce #112. That incident was specific to
2962
+ // `before_tool_call`: a registered approval hook changes how OpenClaw
2963
+ // resolves tool-call approvals for unattended Codex agents, so an
2964
+ // unattended turn waited 120s on a decision nobody could give. `session_end`
2965
+ // is a notification — it cannot block, approve, or delay anything — and its
2966
+ // handler here only frees local state.
2967
+ try {
2968
+ api.on('session_end', (event, ctx) => {
2969
+ interceptorReady?.resetSession();
2970
+ const endedSession = ctx?.sessionId ?? ctx?.sessionKey ?? event?.sessionId ?? event?.sessionKey ?? null;
2971
+ // #226: the scan-unavailable alert window is session state too, and it
2972
+ // is keyed per session — so clear THIS session's window and nobody
2973
+ // else's. Clearing them all would re-arm alerting for every live
2974
+ // session every time any one of them ended.
2975
+ resetScanUnavailableAlertState(endedSession);
2976
+ // #233: a taint must not outlive the conversation that earned it. Same
2977
+ // per-session rule, for the same reason.
2978
+ if (endedSession)
2979
+ sessionTaint.clear(endedSession);
2980
+ });
2981
+ }
2982
+ catch {
2983
+ // session_end may not be a supported hook — TTL safety net handles this
2984
+ }
2985
+ // llm_input/llm_output are CONVERSATION hooks: OpenClaw drops them at
2986
+ // registration for a non-bundled plugin unless the host grants
2987
+ // plugins.entries.<id>.hooks.allowConversationAccess = true. Registration is
2988
+ // still attempted (the host decides, and the grant can be added without a
2989
+ // code change), but they must not be CLAIMED afterwards — see the startup
2990
+ // line below (#225/#230).
1295
2991
  api.on("llm_input", handleLlmInput, { timeoutMs: 30_000 });
1296
2992
  api.on("llm_output", handleLlmOutput, { timeoutMs: 30_000 });
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);
2993
+ // #225: the conversation firewall's ENFORCEMENT point. `llm_input` above is
2994
+ // an OpenClaw *observation* hook — it cannot stop anything, which is why a
2995
+ // detected injection reached the model regardless and the only trace was a
2996
+ // console line. `before_agent_run` is the documented hook that can block a
2997
+ // run, so the verdict lands here where it can actually act.
2998
+ //
2999
+ // Registration is attempted unconditionally: the posture
3000
+ // (off/observe/enforce) decides what happens, and it is read per-call so a
3001
+ // config change takes effect without a restart. Registering conditionally
3002
+ // would make "is the guard wired?" depend on config read at boot — the
3003
+ // exact class of silent gap that #214/#222 were.
3004
+ //
3005
+ // The try/catch is for a host whose `api.on` throws on an unknown name. On
3006
+ // the hosts we have inspected it does NOT throw — an unsupported name is
3007
+ // dropped with a diagnostic, and a conversation hook without the operator's
3008
+ // grant is refused the same way — so a successful call proves only that we
3009
+ // ASKED. That is exactly what the flag is named after, and the honest
3010
+ // reporting comes from the version + consent evidence below.
3011
+ try {
3012
+ api.on("before_agent_run", handleBeforeAgentRun, { timeoutMs: 30_000 });
3013
+ _beforeAgentRunRequested = true;
3014
+ }
3015
+ catch (err) {
3016
+ _beforeAgentRunRequested = false;
3017
+ api.logger?.warn?.(`[shieldcortex] before_agent_run could not be registered on this host (${err instanceof Error ? err.message : String(err)}) — the conversation firewall cannot block on this gateway`);
3018
+ }
3019
+ // The gateway's own message seam, WHERE a host provides one (#143's design
3020
+ // intent: "on OpenClaw the transport should use the gateway's own message
3021
+ // capability"). Probed structurally, never required. No build we have
3022
+ // inspected exposes it — `notifyOperator` appears nowhere in the plugin API
3023
+ // of 2026.5.2 or 2026.7.1 — so on today's hosts this stays null and
3024
+ // conversation alerts go to the webhook.
3025
+ const notifyCtx = api;
3026
+ if (typeof notifyCtx.notifyOperator === 'function') {
3027
+ _gatewayNotifyContext = notifyCtx;
3028
+ }
3029
+ else if (typeof notifyCtx.runtime?.notifyOperator === 'function') {
3030
+ _gatewayNotifyContext = notifyCtx.runtime;
3031
+ }
3032
+ // The operator's conversation-access grant. Read, never written: OpenClaw
3033
+ // refuses every conversation hook for a non-bundled plugin without it, so a
3034
+ // box missing it runs with NO conversation plane at all — and on four of
3035
+ // five fleet hosts surveyed in #222 that was the normal outcome of a
3036
+ // documented install. Report it at boot rather than let the operator infer
3037
+ // protection from a registration line that only states intent.
3038
+ // The host's own in-memory config is the better source (it is what the
3039
+ // loader consulted), so it is preferred; the file the host reads is the
3040
+ // fallback for a runtime that does not expose it. `readConversationAccess`
3041
+ // is #225's shared reader — it also tells us whether the config could be
3042
+ // read at all, which is what keeps "not granted" apart from "cannot tell"
3043
+ // on the startup line below.
3044
+ const diskAccess = readConversationAccess(homedir(), PLUGIN_ID);
3045
+ let rootConfigSeen = false;
3046
+ try {
3047
+ const runtimeConfigApi = api.runtime?.config;
3048
+ const rootConfig = typeof runtimeConfigApi?.current === 'function'
3049
+ ? runtimeConfigApi.current()
3050
+ : typeof runtimeConfigApi?.loadConfig === 'function'
3051
+ ? runtimeConfigApi.loadConfig()
3052
+ : api.config;
3053
+ rootConfigSeen = Boolean(rootConfig) && typeof rootConfig === 'object';
3054
+ _conversationAccessGranted = rootConfigSeen
3055
+ ? readConversationAccessGrant(rootConfig)
3056
+ : diskAccess.granted;
3057
+ }
3058
+ catch {
3059
+ _conversationAccessGranted = diskAccess.granted;
3060
+ }
3061
+ if (!_conversationAccessGranted) {
3062
+ api.logger?.warn?.(`[shieldcortex] conversation firewall INACTIVE: plugins.entries.${PLUGIN_ID}.hooks.allowConversationAccess is not true in openclaw.json — ` +
3063
+ 'the gateway will refuse llm_input, llm_output and before_agent_run for this plugin. Nothing on the conversation path is scanned or blocked. ' +
3064
+ 'This is an operator consent grant and ShieldCortex will never set it for you.');
3065
+ }
3066
+ // #225/#230: this line used to announce `llm_input + llm_output`
3067
+ // unconditionally. On any host without the conversation-access grant the
3068
+ // gateway logged, on the very next two lines, that it had dropped both — so
3069
+ // ShieldCortex was claiming conversation protection it did not have, in the
3070
+ // one place an operator looks to confirm startup. Report only what is
3071
+ // actually live, and name the missing grant when it is the reason.
3072
+ //
3073
+ // `before_agent_run` (#226) is on the same list from 2026.5.9-beta.1, so it
3074
+ // is claimed only when the grant is present AND registration was attempted
3075
+ // this session.
1304
3076
  api.logger.info(`[shieldcortex] v${_version} registered (${describeRegisteredHooks({
1305
- access: conversationAccess,
3077
+ access: {
3078
+ granted: _conversationAccessGranted,
3079
+ // We could read SOMETHING (the host's config or the file) ⇒ the
3080
+ // ungranted state is a fact, not a failed measurement.
3081
+ readable: rootConfigSeen || diskAccess.readable,
3082
+ entryPresent: diskAccess.entryPresent,
3083
+ },
1306
3084
  beforeToolCallRegistered: _beforeToolCallRegistered,
3085
+ beforeAgentRunRequested: _beforeAgentRunRequested,
1307
3086
  })})`);
1308
3087
  }
1309
3088
  catch (err) {