@drakon-systems/shieldcortex-realtime 4.47.23 → 4.47.25

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
@@ -149,6 +149,7 @@ export function __resetConfigStateForTest() {
149
149
  _lastShieldConfigRef = null;
150
150
  _registered = false;
151
151
  _beforeToolCallRegistered = false;
152
+ _registrationError = null;
152
153
  }
153
154
  const INTERCEPT_SEVERITIES = ['low', 'medium', 'high', 'critical'];
154
155
  const INTERCEPT_ACTIONS = ['log', 'warn', 'require_approval'];
@@ -281,7 +282,14 @@ const PLUGIN_CONFIG_JSON_SCHEMA = {
281
282
  type: "object",
282
283
  additionalProperties: false,
283
284
  properties: {
284
- enabled: { type: "boolean" },
285
+ // #115: accepted for schema/UI compatibility but not read — plugin
286
+ // enablement lives host-side at plugins.entries[id].enabled (see
287
+ // rootConfigWith() shape in extractPluginConfig), one level up from this
288
+ // nested `config` object. normaliseConfig() has never populated
289
+ // SCConfig.enabled (no such field exists); documented rather than
290
+ // removed so an existing config.enabled:true/false written by a host UI
291
+ // doesn't fail `additionalProperties:false` validation.
292
+ enabled: { type: "boolean", description: "Unused — plugin on/off is controlled by plugins.entries[id].enabled on the host, not this nested config value." },
285
293
  binaryPath: { type: "string" },
286
294
  cloudApiKey: { type: "string" },
287
295
  cloudBaseUrl: { type: "string" },
@@ -330,7 +338,16 @@ let _registered = false;
330
338
  // unattended Codex agents even a no-op registered hook changes how OpenClaw
331
339
  // resolves approvals).
332
340
  let _beforeToolCallRegistered = false;
333
- function normaliseConfig(raw) {
341
+ // #134 §2: register() wraps its whole body in try/catch so a plugin failure
342
+ // never blocks channel startup — correct, but it used to report the failure
343
+ // with a bare console.warn (bypasses the gateway's structured log, so the
344
+ // operator's only view of a dead security plugin is stdout no one reads) and
345
+ // the /shieldcortex-status command lived INSIDE the same try block, so a
346
+ // crash before that line meant the command never existed at all — the plugin
347
+ // couldn't even honestly report its own death. Set here so the status handler
348
+ // (registered unconditionally, before the risky init work) can read it.
349
+ let _registrationError = null;
350
+ function normaliseConfig(raw, dropped) {
334
351
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
335
352
  return {};
336
353
  const value = raw;
@@ -364,59 +381,107 @@ function normaliseConfig(raw) {
364
381
  // ignored and DEFAULT_INTERCEPTOR_CONFIG re-armed the before_tool_call gate.
365
382
  // Validate-and-preserve every nested interceptor key the manifest schema
366
383
  // accepts; invalid values are dropped individually, valid siblings survive.
367
- const interceptor = normaliseInterceptorConfig(value.interceptor);
384
+ // #115: `dropped` collects the exact key paths any invalid value was
385
+ // dropped from — undefined here (the configSchema.parse() public contract
386
+ // stays a single return value); applyPluginConfigOverride is the one
387
+ // caller that both has a logger AND matters for this (it ingests the
388
+ // openclaw.json plugin entry a human hand-edits — the Edith #112 incident's
389
+ // `enabled:"false"` was exactly this shape of typo).
390
+ const interceptor = normaliseInterceptorConfig(value.interceptor, dropped);
368
391
  if (interceptor)
369
392
  config.interceptor = interceptor;
370
393
  return config;
371
394
  }
372
- function normaliseSeverityMap(raw, allowed) {
395
+ // #115: returns undefined (not {}) when nothing valid was found, matching
396
+ // normaliseInterceptorConfig's contract below — an empty/all-invalid map
397
+ // must read as "absent" so mergeConfigs/applyPluginConfigOverride treat it
398
+ // as no override rather than a truthy-but-empty one.
399
+ function normaliseSeverityMap(raw, allowed, dropped, pathPrefix) {
373
400
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
374
401
  return undefined;
375
402
  const value = raw;
376
403
  const out = {};
377
404
  for (const severity of INTERCEPT_SEVERITIES) {
378
405
  const entry = value[severity];
406
+ if (entry === undefined)
407
+ continue; // absent, not invalid — nothing to warn about
379
408
  if (typeof entry === "string" && allowed.includes(entry)) {
380
409
  out[severity] = entry;
381
410
  }
411
+ else if (pathPrefix) {
412
+ dropped?.push(`${pathPrefix}.${severity}`);
413
+ }
382
414
  }
383
415
  return Object.keys(out).length > 0 ? out : undefined;
384
416
  }
385
- function normaliseInterceptorConfig(raw) {
417
+ function normaliseInterceptorConfig(raw, dropped) {
386
418
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
387
419
  return undefined;
388
420
  const value = raw;
389
421
  const out = {};
390
- if (typeof value.enabled === "boolean")
391
- out.enabled = value.enabled;
392
- const severityActions = normaliseSeverityMap(value.severityActions, INTERCEPT_ACTIONS);
422
+ if (value.enabled !== undefined) {
423
+ if (typeof value.enabled === "boolean")
424
+ out.enabled = value.enabled;
425
+ else
426
+ dropped?.push("interceptor.enabled");
427
+ }
428
+ const severityActions = normaliseSeverityMap(value.severityActions, INTERCEPT_ACTIONS, dropped, "interceptor.severityActions");
393
429
  if (severityActions)
394
430
  out.severityActions = severityActions;
395
- const failurePolicy = normaliseSeverityMap(value.failurePolicy, FAILURE_ACTIONS);
431
+ const failurePolicy = normaliseSeverityMap(value.failurePolicy, FAILURE_ACTIONS, dropped, "interceptor.failurePolicy");
396
432
  if (failurePolicy)
397
433
  out.failurePolicy = failurePolicy;
398
- if (value.actionGuard && typeof value.actionGuard === "object" && !Array.isArray(value.actionGuard)) {
399
- const rawGuard = value.actionGuard;
400
- const guard = {};
401
- if (typeof rawGuard.enabled === "boolean")
402
- guard.enabled = rawGuard.enabled;
403
- if (typeof rawGuard.enforce === "boolean")
404
- guard.enforce = rawGuard.enforce;
405
- if (typeof rawGuard.auditAllows === "boolean")
406
- guard.auditAllows = rawGuard.auditAllows;
407
- if (Array.isArray(rawGuard.autoApprove) && rawGuard.autoApprove.every((entry) => typeof entry === "string")) {
408
- guard.autoApprove = rawGuard.autoApprove;
434
+ if (value.actionGuard !== undefined) {
435
+ if (value.actionGuard && typeof value.actionGuard === "object" && !Array.isArray(value.actionGuard)) {
436
+ const rawGuard = value.actionGuard;
437
+ const guard = {};
438
+ if (rawGuard.enabled !== undefined) {
439
+ if (typeof rawGuard.enabled === "boolean")
440
+ guard.enabled = rawGuard.enabled;
441
+ else
442
+ dropped?.push("interceptor.actionGuard.enabled");
443
+ }
444
+ if (rawGuard.enforce !== undefined) {
445
+ if (typeof rawGuard.enforce === "boolean")
446
+ guard.enforce = rawGuard.enforce;
447
+ else
448
+ dropped?.push("interceptor.actionGuard.enforce");
449
+ }
450
+ if (rawGuard.auditAllows !== undefined) {
451
+ if (typeof rawGuard.auditAllows === "boolean")
452
+ guard.auditAllows = rawGuard.auditAllows;
453
+ else
454
+ dropped?.push("interceptor.actionGuard.auditAllows");
455
+ }
456
+ if (rawGuard.autoApprove !== undefined) {
457
+ if (Array.isArray(rawGuard.autoApprove) && rawGuard.autoApprove.every((entry) => typeof entry === "string")) {
458
+ // #115: defensive copy — downstream (initInterceptor's spread into
459
+ // InterceptorConfig) is read-only today, but aliasing the caller's
460
+ // array means a future in-place mutation of the host config object
461
+ // would silently corrupt the normalised config too.
462
+ guard.autoApprove = [...rawGuard.autoApprove];
463
+ }
464
+ else {
465
+ dropped?.push("interceptor.actionGuard.autoApprove");
466
+ }
467
+ }
468
+ // Carried through untouched — normaliseBrokerConfig is the boundary, and
469
+ // splitting that job across two files is how one of the halves ends up
470
+ // being the lenient one.
471
+ if (rawGuard.broker && typeof rawGuard.broker === "object" && !Array.isArray(rawGuard.broker)) {
472
+ guard.broker = rawGuard.broker;
473
+ }
474
+ if (Object.keys(guard).length > 0)
475
+ out.actionGuard = guard;
409
476
  }
410
- // Carried through untouched — normaliseBrokerConfig is the boundary, and
411
- // splitting that job across two files is how one of the halves ends up
412
- // being the lenient one.
413
- if (rawGuard.broker && typeof rawGuard.broker === "object" && !Array.isArray(rawGuard.broker)) {
414
- guard.broker = rawGuard.broker;
477
+ else {
478
+ dropped?.push("interceptor.actionGuard");
415
479
  }
416
- if (Object.keys(guard).length > 0)
417
- out.actionGuard = guard;
418
480
  }
419
- return out;
481
+ // #115: empty/all-invalid normalises to undefined, not {} — {} is truthy
482
+ // and made applyPluginConfigOverride treat a no-op interceptor block as a
483
+ // real override, inconsistent with normaliseSeverityMap's own contract.
484
+ return Object.keys(out).length > 0 ? out : undefined;
420
485
  }
421
486
  /**
422
487
  * Merge two normalised configs (#112). Semantics:
@@ -448,13 +513,13 @@ function mergeConfigs(base, override) {
448
513
  }
449
514
  return merged;
450
515
  }
451
- function extractPluginConfig(rootConfig) {
516
+ function extractPluginConfig(rootConfig, dropped) {
452
517
  if (!rootConfig || typeof rootConfig !== "object" || Array.isArray(rootConfig))
453
518
  return {};
454
519
  const entries = rootConfig.plugins?.entries;
455
520
  const pluginConfig = entries?.[PLUGIN_ID]?.config ??
456
521
  entries?.[PLUGIN_PACKAGE_NAME]?.config;
457
- return normaliseConfig(pluginConfig);
522
+ return normaliseConfig(pluginConfig, dropped);
458
523
  }
459
524
  function applyPluginConfigOverride(api) {
460
525
  const runtimeConfigApi = api.runtime?.config;
@@ -463,7 +528,16 @@ function applyPluginConfigOverride(api) {
463
528
  : typeof runtimeConfigApi?.loadConfig === "function"
464
529
  ? runtimeConfigApi.loadConfig()
465
530
  : api.config;
466
- const pluginConfig = extractPluginConfig(runtimeConfig);
531
+ // #115: name the exact dropped interceptor key(s) in a warn log. Edith's
532
+ // #112 incident (interceptor.enabled:"false", a string not a boolean) took
533
+ // longer to diagnose than it should have because the drop was silent —
534
+ // this is the one normaliseConfig() call site that both parses the
535
+ // human-edited openclaw.json plugin entry AND has a logger to hand.
536
+ const dropped = [];
537
+ const pluginConfig = extractPluginConfig(runtimeConfig, dropped);
538
+ if (dropped.length > 0) {
539
+ api.logger?.warn?.(`[shieldcortex] plugin config: dropped invalid interceptor key(s), check type/value: ${dropped.join(', ')}`);
540
+ }
467
541
  if (Object.keys(pluginConfig).length === 0)
468
542
  return;
469
543
  _configOverride = mergeConfigs(_configOverride ?? {}, pluginConfig);
@@ -944,11 +1018,69 @@ export default {
944
1018
  if (_registered)
945
1019
  return;
946
1020
  _registered = true;
1021
+ // --- Interceptor (lazy init) ---
1022
+ let interceptorReady = null;
1023
+ let interceptorInitAttempted = false;
1024
+ // #134 §2: registered UNCONDITIONALLY, before the try block below that can
1025
+ // throw. Previously this command lived inside that try, so a plugin crash
1026
+ // meant the operator had no /shieldcortex-status to run at all — the one
1027
+ // place they'd look reported nothing, same as it not existing. Now it
1028
+ // always exists, and honestly reports DEGRADED when init failed instead of
1029
+ // rendering config-derived lines that describe a state the plugin never
1030
+ // reached.
1031
+ try {
1032
+ api.registerCommand({
1033
+ name: "shieldcortex-status",
1034
+ description: "Show ShieldCortex real-time scanner status",
1035
+ async handler() {
1036
+ if (_registrationError) {
1037
+ return {
1038
+ text: `ShieldCortex v${_version}\n` +
1039
+ ` STATUS: DEGRADED — plugin failed to initialize: ${_registrationError}\n` +
1040
+ ` Real-time scanning, memory capture and the Action Guard before_tool_call\n` +
1041
+ ` hook are ALL INACTIVE. Channels started normally (fail-open by design —\n` +
1042
+ ` a broken security plugin must never block the gateway), but this plugin\n` +
1043
+ ` is doing nothing until the underlying error is fixed and the gateway is\n` +
1044
+ ` restarted.`,
1045
+ };
1046
+ }
1047
+ const cfg = await loadConfig();
1048
+ const autoMemory = isAutoMemoryEnabled(cfg) ? "on" : "off";
1049
+ const dedupe = isAutoMemoryDedupeEnabled(cfg) ? "on" : "off";
1050
+ const cloud = cfg.cloudApiKey ? "configured" : "not configured";
1051
+ // Resolve the Action Guard state the same way initInterceptor() does,
1052
+ // so the status line reflects what before_tool_call will actually do.
1053
+ const rawInterceptor = cfg.interceptor;
1054
+ const guardCfg = {
1055
+ ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] }),
1056
+ ...(rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.actionGuard ?? {} : {}),
1057
+ };
1058
+ const interceptorOn = (rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.enabled : undefined) ?? DEFAULT_INTERCEPTOR_CONFIG.enabled;
1059
+ const autoApproved = Array.isArray(guardCfg.autoApprove) ? guardCfg.autoApprove.length : 0;
1060
+ const guardState = !_beforeToolCallRegistered
1061
+ ? "off (before_tool_call not registered — interceptor disabled in plugin config)"
1062
+ : !interceptorOn || !guardCfg.enabled
1063
+ ? "off"
1064
+ : `${guardCfg.enforce ? "enforce" : "warn"}${autoApproved > 0 ? ` (${autoApproved} auto-approved)` : ""}${interceptorReady ? "" : " — not yet initialised this session"}`;
1065
+ const hooksLine = _beforeToolCallRegistered
1066
+ ? "llm_input (scan), llm_output (memory), before_tool_call (action guard), session_end (cache reset)"
1067
+ : "llm_input (scan), llm_output (memory)";
1068
+ return {
1069
+ text: `ShieldCortex v${_version}\n` +
1070
+ ` Hooks: ${hooksLine}\n` +
1071
+ ` Action guard: ${guardState}\n` +
1072
+ ` Auto memory: ${autoMemory} | Dedupe: ${dedupe}\n` +
1073
+ ` Cloud sync: ${cloud}`,
1074
+ };
1075
+ },
1076
+ });
1077
+ }
1078
+ catch {
1079
+ // Host doesn't support registerCommand at all — nothing more to do here;
1080
+ // the try/catch below still logs the substantive init failure loudly.
1081
+ }
947
1082
  try {
948
1083
  applyPluginConfigOverride(api);
949
- // --- Interceptor (lazy init) ---
950
- let interceptorReady = null;
951
- let interceptorInitAttempted = false;
952
1084
  async function initInterceptor() {
953
1085
  if (interceptorInitAttempted)
954
1086
  return interceptorReady;
@@ -1042,48 +1174,29 @@ export default {
1042
1174
  }
1043
1175
  api.on("llm_input", handleLlmInput, { timeoutMs: 30_000 });
1044
1176
  api.on("llm_output", handleLlmOutput, { timeoutMs: 30_000 });
1045
- // Register a lightweight status command so the plugin is not hook-only
1046
- api.registerCommand({
1047
- name: "shieldcortex-status",
1048
- description: "Show ShieldCortex real-time scanner status",
1049
- async handler() {
1050
- const cfg = await loadConfig();
1051
- const autoMemory = isAutoMemoryEnabled(cfg) ? "on" : "off";
1052
- const dedupe = isAutoMemoryDedupeEnabled(cfg) ? "on" : "off";
1053
- const cloud = cfg.cloudApiKey ? "configured" : "not configured";
1054
- // Resolve the Action Guard state the same way initInterceptor() does,
1055
- // so the status line reflects what before_tool_call will actually do.
1056
- const rawInterceptor = cfg.interceptor;
1057
- const guardCfg = {
1058
- ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] }),
1059
- ...(rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.actionGuard ?? {} : {}),
1060
- };
1061
- const interceptorOn = (rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.enabled : undefined) ?? DEFAULT_INTERCEPTOR_CONFIG.enabled;
1062
- const autoApproved = Array.isArray(guardCfg.autoApprove) ? guardCfg.autoApprove.length : 0;
1063
- const guardState = !_beforeToolCallRegistered
1064
- ? "off (before_tool_call not registered — interceptor disabled in plugin config)"
1065
- : !interceptorOn || !guardCfg.enabled
1066
- ? "off"
1067
- : `${guardCfg.enforce ? "enforce" : "warn"}${autoApproved > 0 ? ` (${autoApproved} auto-approved)` : ""}${interceptorReady ? "" : " — not yet initialised this session"}`;
1068
- const hooksLine = _beforeToolCallRegistered
1069
- ? "llm_input (scan), llm_output (memory), before_tool_call (action guard), session_end (cache reset)"
1070
- : "llm_input (scan), llm_output (memory)";
1071
- return {
1072
- text: `ShieldCortex v${_version}\n` +
1073
- ` Hooks: ${hooksLine}\n` +
1074
- ` Action guard: ${guardState}\n` +
1075
- ` Auto memory: ${autoMemory} | Dedupe: ${dedupe}\n` +
1076
- ` Cloud sync: ${cloud}`,
1077
- };
1078
- },
1079
- });
1080
1177
  api.logger.info(`[shieldcortex] v${_version} registered (llm_input + llm_output${_beforeToolCallRegistered ? " + before_tool_call" : ""} + /shieldcortex-status)`);
1081
1178
  }
1082
1179
  catch (err) {
1083
- // Plugin must never block channel startup — warn and bail gracefully
1180
+ // Plugin must never block channel startup — warn and bail gracefully.
1181
+ // #134 §2: this used to be a bare console.warn, which bypasses the
1182
+ // gateway's structured log entirely — real-time scanning, memory
1183
+ // capture and the Action Guard before_tool_call hook all go dark with
1184
+ // no [plugins] line anywhere an operator looks. Route through
1185
+ // api.logger.warn (the structured channel) and only fall back to
1186
+ // console.error — never console.warn, so a fallback line is visibly
1187
+ // distinct from a routed one — if the host doesn't even provide a
1188
+ // logger, which would itself be a broken host.
1084
1189
  const msg = err instanceof Error ? err.message : String(err);
1085
- console.warn(`[shieldcortex] WARNING: Plugin failed to initialize: ${msg}`);
1086
- console.warn('[shieldcortex] Real-time scanning is disabled. Channels will start normally.');
1190
+ _registrationError = msg;
1191
+ const warn = api.logger?.warn;
1192
+ if (typeof warn === 'function') {
1193
+ warn.call(api.logger, `[shieldcortex] WARNING: Plugin failed to initialize: ${msg}`);
1194
+ warn.call(api.logger, '[shieldcortex] Real-time scanning is disabled. Channels will start normally.');
1195
+ }
1196
+ else {
1197
+ console.error(`[shieldcortex] WARNING: Plugin failed to initialize (no api.logger available): ${msg}`);
1198
+ console.error('[shieldcortex] Real-time scanning is disabled. Channels will start normally.');
1199
+ }
1087
1200
  }
1088
1201
  },
1089
1202
  };
@@ -355,8 +355,21 @@ function xrayMemoryGuard(content, title) {
355
355
  // * anything over the size cap is refused (the guard then records it as
356
356
  // `opaque-script-invocation` rather than pretending it was scanned);
357
357
  // * every error returns `null`. Nothing escapes.
358
- const MAX_SCRIPT_SOURCE_BYTES = 262_144; // 256KB — matches the guard core's cap
359
- const UNREADABLE_PATH_PREFIX = /^\/(?:proc|sys|dev)\//;
358
+ // The resolver is DUPLICATED here, deliberately (#160).
359
+ //
360
+ // This plugin publishes as its own npm package and builds standalone
361
+ // (tsconfig.openclaw-plugin.json pins rootDir to plugins/openclaw), so it
362
+ // genuinely cannot import from src/ — converging by import broke the plugin
363
+ // build outright. A real constraint, then, not a tidiness failure.
364
+ //
365
+ // But two copies of a safety-railed file reader reached from untrusted tool
366
+ // input is two chances to drift, and drift is what #160 was about. So the
367
+ // copies are held together by a BEHAVIOURAL drift test that runs both
368
+ // implementations over one fixture table and requires identical answers
369
+ // (src/__tests__/enforcement-surface-parity.test.ts). Guard the duplication
370
+ // rather than pretend it away.
371
+ export const MAX_SCRIPT_SOURCE_BYTES = 262_144; // 256KB — matches the guard core's cap
372
+ export const UNREADABLE_PATH_PREFIX = /^\/(?:proc|sys|dev)\//;
360
373
  export function createScriptSourceResolver(cwd) {
361
374
  const base = cwd && typeof cwd === 'string' ? cwd : process.cwd();
362
375
  return (scriptPath) => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.23",
3
+ "version": "4.47.25",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
@@ -109,7 +109,8 @@
109
109
  "additionalProperties": false,
110
110
  "properties": {
111
111
  "enabled": {
112
- "type": "boolean"
112
+ "type": "boolean",
113
+ "description": "Unused — plugin on/off is controlled by plugins.entries[id].enabled on the host, not this nested config value. See #115."
113
114
  },
114
115
  "binaryPath": {
115
116
  "type": "string"
@@ -138,6 +139,7 @@
138
139
  },
139
140
  "interceptor": {
140
141
  "type": "object",
142
+ "additionalProperties": false,
141
143
  "properties": {
142
144
  "enabled": {
143
145
  "type": "boolean",
package/index.ts CHANGED
@@ -184,6 +184,7 @@ export function __resetConfigStateForTest(): void {
184
184
  _lastShieldConfigRef = null;
185
185
  _registered = false;
186
186
  _beforeToolCallRegistered = false;
187
+ _registrationError = null;
187
188
  }
188
189
 
189
190
  type LlmInputEvent = {
@@ -398,7 +399,14 @@ const PLUGIN_CONFIG_JSON_SCHEMA = {
398
399
  type: "object",
399
400
  additionalProperties: false,
400
401
  properties: {
401
- enabled: { type: "boolean" },
402
+ // #115: accepted for schema/UI compatibility but not read — plugin
403
+ // enablement lives host-side at plugins.entries[id].enabled (see
404
+ // rootConfigWith() shape in extractPluginConfig), one level up from this
405
+ // nested `config` object. normaliseConfig() has never populated
406
+ // SCConfig.enabled (no such field exists); documented rather than
407
+ // removed so an existing config.enabled:true/false written by a host UI
408
+ // doesn't fail `additionalProperties:false` validation.
409
+ enabled: { type: "boolean", description: "Unused — plugin on/off is controlled by plugins.entries[id].enabled on the host, not this nested config value." },
402
410
  binaryPath: { type: "string" },
403
411
  cloudApiKey: { type: "string" },
404
412
  cloudBaseUrl: { type: "string" },
@@ -447,8 +455,17 @@ let _registered = false;
447
455
  // unattended Codex agents even a no-op registered hook changes how OpenClaw
448
456
  // resolves approvals).
449
457
  let _beforeToolCallRegistered = false;
450
-
451
- function normaliseConfig(raw: unknown): SCConfig {
458
+ // #134 §2: register() wraps its whole body in try/catch so a plugin failure
459
+ // never blocks channel startup — correct, but it used to report the failure
460
+ // with a bare console.warn (bypasses the gateway's structured log, so the
461
+ // operator's only view of a dead security plugin is stdout no one reads) and
462
+ // the /shieldcortex-status command lived INSIDE the same try block, so a
463
+ // crash before that line meant the command never existed at all — the plugin
464
+ // couldn't even honestly report its own death. Set here so the status handler
465
+ // (registered unconditionally, before the risky init work) can read it.
466
+ let _registrationError: string | null = null;
467
+
468
+ function normaliseConfig(raw: unknown, dropped?: string[]): SCConfig {
452
469
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
453
470
 
454
471
  const value = raw as Record<string, unknown>;
@@ -484,60 +501,102 @@ function normaliseConfig(raw: unknown): SCConfig {
484
501
  // ignored and DEFAULT_INTERCEPTOR_CONFIG re-armed the before_tool_call gate.
485
502
  // Validate-and-preserve every nested interceptor key the manifest schema
486
503
  // accepts; invalid values are dropped individually, valid siblings survive.
487
- const interceptor = normaliseInterceptorConfig(value.interceptor);
504
+ // #115: `dropped` collects the exact key paths any invalid value was
505
+ // dropped from — undefined here (the configSchema.parse() public contract
506
+ // stays a single return value); applyPluginConfigOverride is the one
507
+ // caller that both has a logger AND matters for this (it ingests the
508
+ // openclaw.json plugin entry a human hand-edits — the Edith #112 incident's
509
+ // `enabled:"false"` was exactly this shape of typo).
510
+ const interceptor = normaliseInterceptorConfig(value.interceptor, dropped);
488
511
  if (interceptor) config.interceptor = interceptor;
489
512
 
490
513
  return config;
491
514
  }
492
515
 
516
+ // #115: returns undefined (not {}) when nothing valid was found, matching
517
+ // normaliseInterceptorConfig's contract below — an empty/all-invalid map
518
+ // must read as "absent" so mergeConfigs/applyPluginConfigOverride treat it
519
+ // as no override rather than a truthy-but-empty one.
493
520
  function normaliseSeverityMap<A extends string>(
494
521
  raw: unknown,
495
522
  allowed: readonly A[],
523
+ dropped?: string[],
524
+ pathPrefix?: string,
496
525
  ): Partial<Record<InterceptSeverity, A>> | undefined {
497
526
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
498
527
  const value = raw as Record<string, unknown>;
499
528
  const out: Partial<Record<InterceptSeverity, A>> = {};
500
529
  for (const severity of INTERCEPT_SEVERITIES) {
501
530
  const entry = value[severity];
531
+ if (entry === undefined) continue; // absent, not invalid — nothing to warn about
502
532
  if (typeof entry === "string" && (allowed as readonly string[]).includes(entry)) {
503
533
  out[severity] = entry as A;
534
+ } else if (pathPrefix) {
535
+ dropped?.push(`${pathPrefix}.${severity}`);
504
536
  }
505
537
  }
506
538
  return Object.keys(out).length > 0 ? out : undefined;
507
539
  }
508
540
 
509
- function normaliseInterceptorConfig(raw: unknown): InterceptorUserConfig | undefined {
541
+ function normaliseInterceptorConfig(raw: unknown, dropped?: string[]): InterceptorUserConfig | undefined {
510
542
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
511
543
  const value = raw as Record<string, unknown>;
512
544
  const out: InterceptorUserConfig = {};
513
545
 
514
- if (typeof value.enabled === "boolean") out.enabled = value.enabled;
546
+ if (value.enabled !== undefined) {
547
+ if (typeof value.enabled === "boolean") out.enabled = value.enabled;
548
+ else dropped?.push("interceptor.enabled");
549
+ }
515
550
 
516
- const severityActions = normaliseSeverityMap(value.severityActions, INTERCEPT_ACTIONS);
551
+ const severityActions = normaliseSeverityMap(value.severityActions, INTERCEPT_ACTIONS, dropped, "interceptor.severityActions");
517
552
  if (severityActions) out.severityActions = severityActions;
518
553
 
519
- const failurePolicy = normaliseSeverityMap(value.failurePolicy, FAILURE_ACTIONS);
554
+ const failurePolicy = normaliseSeverityMap(value.failurePolicy, FAILURE_ACTIONS, dropped, "interceptor.failurePolicy");
520
555
  if (failurePolicy) out.failurePolicy = failurePolicy;
521
556
 
522
- if (value.actionGuard && typeof value.actionGuard === "object" && !Array.isArray(value.actionGuard)) {
523
- const rawGuard = value.actionGuard as Record<string, unknown>;
524
- const guard: NonNullable<InterceptorUserConfig["actionGuard"]> = {};
525
- if (typeof rawGuard.enabled === "boolean") guard.enabled = rawGuard.enabled;
526
- if (typeof rawGuard.enforce === "boolean") guard.enforce = rawGuard.enforce;
527
- if (typeof rawGuard.auditAllows === "boolean") guard.auditAllows = rawGuard.auditAllows;
528
- if (Array.isArray(rawGuard.autoApprove) && rawGuard.autoApprove.every((entry) => typeof entry === "string")) {
529
- guard.autoApprove = rawGuard.autoApprove as string[];
530
- }
531
- // Carried through untouched — normaliseBrokerConfig is the boundary, and
532
- // splitting that job across two files is how one of the halves ends up
533
- // being the lenient one.
534
- if (rawGuard.broker && typeof rawGuard.broker === "object" && !Array.isArray(rawGuard.broker)) {
535
- guard.broker = rawGuard.broker as Record<string, unknown>;
557
+ if (value.actionGuard !== undefined) {
558
+ if (value.actionGuard && typeof value.actionGuard === "object" && !Array.isArray(value.actionGuard)) {
559
+ const rawGuard = value.actionGuard as Record<string, unknown>;
560
+ const guard: NonNullable<InterceptorUserConfig["actionGuard"]> = {};
561
+ if (rawGuard.enabled !== undefined) {
562
+ if (typeof rawGuard.enabled === "boolean") guard.enabled = rawGuard.enabled;
563
+ else dropped?.push("interceptor.actionGuard.enabled");
564
+ }
565
+ if (rawGuard.enforce !== undefined) {
566
+ if (typeof rawGuard.enforce === "boolean") guard.enforce = rawGuard.enforce;
567
+ else dropped?.push("interceptor.actionGuard.enforce");
568
+ }
569
+ if (rawGuard.auditAllows !== undefined) {
570
+ if (typeof rawGuard.auditAllows === "boolean") guard.auditAllows = rawGuard.auditAllows;
571
+ else dropped?.push("interceptor.actionGuard.auditAllows");
572
+ }
573
+ if (rawGuard.autoApprove !== undefined) {
574
+ if (Array.isArray(rawGuard.autoApprove) && rawGuard.autoApprove.every((entry) => typeof entry === "string")) {
575
+ // #115: defensive copy — downstream (initInterceptor's spread into
576
+ // InterceptorConfig) is read-only today, but aliasing the caller's
577
+ // array means a future in-place mutation of the host config object
578
+ // would silently corrupt the normalised config too.
579
+ guard.autoApprove = [...(rawGuard.autoApprove as string[])];
580
+ } else {
581
+ dropped?.push("interceptor.actionGuard.autoApprove");
582
+ }
583
+ }
584
+ // Carried through untouched — normaliseBrokerConfig is the boundary, and
585
+ // splitting that job across two files is how one of the halves ends up
586
+ // being the lenient one.
587
+ if (rawGuard.broker && typeof rawGuard.broker === "object" && !Array.isArray(rawGuard.broker)) {
588
+ guard.broker = rawGuard.broker as Record<string, unknown>;
589
+ }
590
+ if (Object.keys(guard).length > 0) out.actionGuard = guard;
591
+ } else {
592
+ dropped?.push("interceptor.actionGuard");
536
593
  }
537
- if (Object.keys(guard).length > 0) out.actionGuard = guard;
538
594
  }
539
595
 
540
- return out;
596
+ // #115: empty/all-invalid normalises to undefined, not {} — {} is truthy
597
+ // and made applyPluginConfigOverride treat a no-op interceptor block as a
598
+ // real override, inconsistent with normaliseSeverityMap's own contract.
599
+ return Object.keys(out).length > 0 ? out : undefined;
541
600
  }
542
601
 
543
602
  /**
@@ -571,7 +630,7 @@ function mergeConfigs(base: SCConfig, override: SCConfig): SCConfig {
571
630
  return merged;
572
631
  }
573
632
 
574
- function extractPluginConfig(rootConfig: unknown): SCConfig {
633
+ function extractPluginConfig(rootConfig: unknown, dropped?: string[]): SCConfig {
575
634
  if (!rootConfig || typeof rootConfig !== "object" || Array.isArray(rootConfig)) return {};
576
635
  const entries = (rootConfig as {
577
636
  plugins?: {
@@ -583,7 +642,7 @@ function extractPluginConfig(rootConfig: unknown): SCConfig {
583
642
  entries?.[PLUGIN_ID]?.config ??
584
643
  entries?.[PLUGIN_PACKAGE_NAME]?.config;
585
644
 
586
- return normaliseConfig(pluginConfig);
645
+ return normaliseConfig(pluginConfig, dropped);
587
646
  }
588
647
 
589
648
  function applyPluginConfigOverride(api: PluginApi): void {
@@ -593,7 +652,18 @@ function applyPluginConfigOverride(api: PluginApi): void {
593
652
  : typeof runtimeConfigApi?.loadConfig === "function"
594
653
  ? runtimeConfigApi.loadConfig()
595
654
  : api.config;
596
- const pluginConfig = extractPluginConfig(runtimeConfig);
655
+ // #115: name the exact dropped interceptor key(s) in a warn log. Edith's
656
+ // #112 incident (interceptor.enabled:"false", a string not a boolean) took
657
+ // longer to diagnose than it should have because the drop was silent —
658
+ // this is the one normaliseConfig() call site that both parses the
659
+ // human-edited openclaw.json plugin entry AND has a logger to hand.
660
+ const dropped: string[] = [];
661
+ const pluginConfig = extractPluginConfig(runtimeConfig, dropped);
662
+ if (dropped.length > 0) {
663
+ (api.logger as any)?.warn?.(
664
+ `[shieldcortex] plugin config: dropped invalid interceptor key(s), check type/value: ${dropped.join(', ')}`,
665
+ );
666
+ }
597
667
  if (Object.keys(pluginConfig).length === 0) return;
598
668
  _configOverride = mergeConfigs(_configOverride ?? {}, pluginConfig);
599
669
  // Override changed — invalidate so loadConfig() re-merges with new override.
@@ -1129,13 +1199,74 @@ export default {
1129
1199
  register(api: PluginApi) {
1130
1200
  if (_registered) return;
1131
1201
  _registered = true;
1132
- try {
1133
- applyPluginConfigOverride(api);
1134
1202
 
1135
1203
  // --- Interceptor (lazy init) ---
1136
1204
  let interceptorReady: ReturnType<typeof createInterceptor> | null = null;
1137
1205
  let interceptorInitAttempted = false;
1138
1206
 
1207
+ // #134 §2: registered UNCONDITIONALLY, before the try block below that can
1208
+ // throw. Previously this command lived inside that try, so a plugin crash
1209
+ // meant the operator had no /shieldcortex-status to run at all — the one
1210
+ // place they'd look reported nothing, same as it not existing. Now it
1211
+ // always exists, and honestly reports DEGRADED when init failed instead of
1212
+ // rendering config-derived lines that describe a state the plugin never
1213
+ // reached.
1214
+ try {
1215
+ api.registerCommand({
1216
+ name: "shieldcortex-status",
1217
+ description: "Show ShieldCortex real-time scanner status",
1218
+ async handler() {
1219
+ if (_registrationError) {
1220
+ return {
1221
+ text:
1222
+ `ShieldCortex v${_version}\n` +
1223
+ ` STATUS: DEGRADED — plugin failed to initialize: ${_registrationError}\n` +
1224
+ ` Real-time scanning, memory capture and the Action Guard before_tool_call\n` +
1225
+ ` hook are ALL INACTIVE. Channels started normally (fail-open by design —\n` +
1226
+ ` a broken security plugin must never block the gateway), but this plugin\n` +
1227
+ ` is doing nothing until the underlying error is fixed and the gateway is\n` +
1228
+ ` restarted.`,
1229
+ };
1230
+ }
1231
+ const cfg = await loadConfig();
1232
+ const autoMemory = isAutoMemoryEnabled(cfg) ? "on" : "off";
1233
+ const dedupe = isAutoMemoryDedupeEnabled(cfg) ? "on" : "off";
1234
+ const cloud = cfg.cloudApiKey ? "configured" : "not configured";
1235
+ // Resolve the Action Guard state the same way initInterceptor() does,
1236
+ // so the status line reflects what before_tool_call will actually do.
1237
+ const rawInterceptor = cfg.interceptor;
1238
+ const guardCfg = {
1239
+ ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] }),
1240
+ ...(rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.actionGuard ?? {} : {}),
1241
+ };
1242
+ const interceptorOn = (rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.enabled : undefined) ?? DEFAULT_INTERCEPTOR_CONFIG.enabled;
1243
+ const autoApproved = Array.isArray(guardCfg.autoApprove) ? guardCfg.autoApprove.length : 0;
1244
+ const guardState = !_beforeToolCallRegistered
1245
+ ? "off (before_tool_call not registered — interceptor disabled in plugin config)"
1246
+ : !interceptorOn || !guardCfg.enabled
1247
+ ? "off"
1248
+ : `${guardCfg.enforce ? "enforce" : "warn"}${autoApproved > 0 ? ` (${autoApproved} auto-approved)` : ""}${interceptorReady ? "" : " — not yet initialised this session"}`;
1249
+ const hooksLine = _beforeToolCallRegistered
1250
+ ? "llm_input (scan), llm_output (memory), before_tool_call (action guard), session_end (cache reset)"
1251
+ : "llm_input (scan), llm_output (memory)";
1252
+ return {
1253
+ text:
1254
+ `ShieldCortex v${_version}\n` +
1255
+ ` Hooks: ${hooksLine}\n` +
1256
+ ` Action guard: ${guardState}\n` +
1257
+ ` Auto memory: ${autoMemory} | Dedupe: ${dedupe}\n` +
1258
+ ` Cloud sync: ${cloud}`,
1259
+ };
1260
+ },
1261
+ });
1262
+ } catch {
1263
+ // Host doesn't support registerCommand at all — nothing more to do here;
1264
+ // the try/catch below still logs the substantive init failure loudly.
1265
+ }
1266
+
1267
+ try {
1268
+ applyPluginConfigOverride(api);
1269
+
1139
1270
  async function initInterceptor(): Promise<ReturnType<typeof createInterceptor> | null> {
1140
1271
  if (interceptorInitAttempted) return interceptorReady;
1141
1272
  interceptorInitAttempted = true;
@@ -1231,49 +1362,27 @@ export default {
1231
1362
  api.on("llm_input", handleLlmInput, { timeoutMs: 30_000 });
1232
1363
  api.on("llm_output", handleLlmOutput, { timeoutMs: 30_000 });
1233
1364
 
1234
- // Register a lightweight status command so the plugin is not hook-only
1235
- api.registerCommand({
1236
- name: "shieldcortex-status",
1237
- description: "Show ShieldCortex real-time scanner status",
1238
- async handler() {
1239
- const cfg = await loadConfig();
1240
- const autoMemory = isAutoMemoryEnabled(cfg) ? "on" : "off";
1241
- const dedupe = isAutoMemoryDedupeEnabled(cfg) ? "on" : "off";
1242
- const cloud = cfg.cloudApiKey ? "configured" : "not configured";
1243
- // Resolve the Action Guard state the same way initInterceptor() does,
1244
- // so the status line reflects what before_tool_call will actually do.
1245
- const rawInterceptor = cfg.interceptor;
1246
- const guardCfg = {
1247
- ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] }),
1248
- ...(rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.actionGuard ?? {} : {}),
1249
- };
1250
- const interceptorOn = (rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.enabled : undefined) ?? DEFAULT_INTERCEPTOR_CONFIG.enabled;
1251
- const autoApproved = Array.isArray(guardCfg.autoApprove) ? guardCfg.autoApprove.length : 0;
1252
- const guardState = !_beforeToolCallRegistered
1253
- ? "off (before_tool_call not registered — interceptor disabled in plugin config)"
1254
- : !interceptorOn || !guardCfg.enabled
1255
- ? "off"
1256
- : `${guardCfg.enforce ? "enforce" : "warn"}${autoApproved > 0 ? ` (${autoApproved} auto-approved)` : ""}${interceptorReady ? "" : " — not yet initialised this session"}`;
1257
- const hooksLine = _beforeToolCallRegistered
1258
- ? "llm_input (scan), llm_output (memory), before_tool_call (action guard), session_end (cache reset)"
1259
- : "llm_input (scan), llm_output (memory)";
1260
- return {
1261
- text:
1262
- `ShieldCortex v${_version}\n` +
1263
- ` Hooks: ${hooksLine}\n` +
1264
- ` Action guard: ${guardState}\n` +
1265
- ` Auto memory: ${autoMemory} | Dedupe: ${dedupe}\n` +
1266
- ` Cloud sync: ${cloud}`,
1267
- };
1268
- },
1269
- });
1270
-
1271
1365
  api.logger.info(`[shieldcortex] v${_version} registered (llm_input + llm_output${_beforeToolCallRegistered ? " + before_tool_call" : ""} + /shieldcortex-status)`);
1272
1366
  } catch (err) {
1273
- // Plugin must never block channel startup — warn and bail gracefully
1367
+ // Plugin must never block channel startup — warn and bail gracefully.
1368
+ // #134 §2: this used to be a bare console.warn, which bypasses the
1369
+ // gateway's structured log entirely — real-time scanning, memory
1370
+ // capture and the Action Guard before_tool_call hook all go dark with
1371
+ // no [plugins] line anywhere an operator looks. Route through
1372
+ // api.logger.warn (the structured channel) and only fall back to
1373
+ // console.error — never console.warn, so a fallback line is visibly
1374
+ // distinct from a routed one — if the host doesn't even provide a
1375
+ // logger, which would itself be a broken host.
1274
1376
  const msg = err instanceof Error ? err.message : String(err);
1275
- console.warn(`[shieldcortex] WARNING: Plugin failed to initialize: ${msg}`);
1276
- console.warn('[shieldcortex] Real-time scanning is disabled. Channels will start normally.');
1377
+ _registrationError = msg;
1378
+ const warn = (api.logger as any)?.warn;
1379
+ if (typeof warn === 'function') {
1380
+ warn.call(api.logger, `[shieldcortex] WARNING: Plugin failed to initialize: ${msg}`);
1381
+ warn.call(api.logger, '[shieldcortex] Real-time scanning is disabled. Channels will start normally.');
1382
+ } else {
1383
+ console.error(`[shieldcortex] WARNING: Plugin failed to initialize (no api.logger available): ${msg}`);
1384
+ console.error('[shieldcortex] Real-time scanning is disabled. Channels will start normally.');
1385
+ }
1277
1386
  }
1278
1387
  },
1279
1388
  };
package/interceptor.ts CHANGED
@@ -604,8 +604,21 @@ type PipelineRunner = (content: string, title: string, source: { type: string; i
604
604
  // * anything over the size cap is refused (the guard then records it as
605
605
  // `opaque-script-invocation` rather than pretending it was scanned);
606
606
  // * every error returns `null`. Nothing escapes.
607
- const MAX_SCRIPT_SOURCE_BYTES = 262_144; // 256KB — matches the guard core's cap
608
- const UNREADABLE_PATH_PREFIX = /^\/(?:proc|sys|dev)\//;
607
+ // The resolver is DUPLICATED here, deliberately (#160).
608
+ //
609
+ // This plugin publishes as its own npm package and builds standalone
610
+ // (tsconfig.openclaw-plugin.json pins rootDir to plugins/openclaw), so it
611
+ // genuinely cannot import from src/ — converging by import broke the plugin
612
+ // build outright. A real constraint, then, not a tidiness failure.
613
+ //
614
+ // But two copies of a safety-railed file reader reached from untrusted tool
615
+ // input is two chances to drift, and drift is what #160 was about. So the
616
+ // copies are held together by a BEHAVIOURAL drift test that runs both
617
+ // implementations over one fixture table and requires identical answers
618
+ // (src/__tests__/enforcement-surface-parity.test.ts). Guard the duplication
619
+ // rather than pretend it away.
620
+ export const MAX_SCRIPT_SOURCE_BYTES = 262_144; // 256KB — matches the guard core's cap
621
+ export const UNREADABLE_PATH_PREFIX = /^\/(?:proc|sys|dev)\//;
609
622
 
610
623
  export function createScriptSourceResolver(cwd?: string): (scriptPath: string) => string | null {
611
624
  const base = cwd && typeof cwd === 'string' ? cwd : process.cwd();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.23",
3
+ "version": "4.47.25",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
@@ -109,7 +109,8 @@
109
109
  "additionalProperties": false,
110
110
  "properties": {
111
111
  "enabled": {
112
- "type": "boolean"
112
+ "type": "boolean",
113
+ "description": "Unused — plugin on/off is controlled by plugins.entries[id].enabled on the host, not this nested config value. See #115."
113
114
  },
114
115
  "binaryPath": {
115
116
  "type": "string"
@@ -138,6 +139,7 @@
138
139
  },
139
140
  "interceptor": {
140
141
  "type": "object",
142
+ "additionalProperties": false,
141
143
  "properties": {
142
144
  "enabled": {
143
145
  "type": "boolean",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.47.23",
3
+ "version": "4.47.25",
4
4
  "description": "OpenClaw plugin for ShieldCortex real-time defence scanning and optional memory extraction.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",