@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 +185 -72
- package/dist/interceptor.js +15 -2
- package/dist/openclaw.plugin.json +4 -2
- package/index.ts +178 -69
- package/interceptor.ts +15 -2
- package/openclaw.plugin.json +4 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
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
|
};
|
package/dist/interceptor.js
CHANGED
|
@@ -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
|
-
|
|
359
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
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
|
-
|
|
608
|
-
|
|
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();
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shieldcortex-realtime",
|
|
3
|
-
"version": "4.47.
|
|
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