@drakon-systems/shieldcortex-realtime 4.54.13 → 4.54.15

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/README.md CHANGED
@@ -136,8 +136,8 @@ are read by the parser; on a conflicting key the top-level value wins, per key,
136
136
  and anything the top-level block does not mention is filled in from the alias.
137
137
  Everything below is relative to whichever of the two you use:
138
138
 
139
- - `actionGuard.enabled`: turn the before-tool-call Action Guard on or off (default `true`)
140
- - `actionGuard.enforce`: enforce dangerous-operation gating (default `true`); `false` opts down to warn-and-allow. Catastrophic operations are blocked regardless.
139
+ - `actionGuard.enabled`: turn the before-tool-call Action Guard on or off (default `false`; unsigned configs leave Guard off)
140
+ - `actionGuard.enforce`: enforce dangerous-operation gating (default `true` when Guard is on); `false` opts down to warn-and-allow. Catastrophic operations are blocked only while Guard is enabled.
141
141
  - `actionGuard.autoApprove`: array of operation allowlist entries for unattended agents that legitimately need specific dangerous operations
142
142
  - `actionGuard.auditAllows`: audit recognised (sensitive-tier) allow-decisions so "scanned & allowed" is distinguishable from "never scanned" (default `true`; benign allows are never audited)
143
143
  - `actionGuard.notify`: operator-notification transport (`enabled`, `webhookUrl`, `webhookSecret`, `openclaw`, `timeoutMs`). Off unless `enabled` is exactly `true`. Used both for held tool calls and for conversation-firewall detections.
package/dist/index.js CHANGED
@@ -821,7 +821,7 @@ const PLUGIN_CONFIG_UI_HINTS = {
821
821
  },
822
822
  "interceptor.actionGuard.enabled": {
823
823
  label: "Action Guard",
824
- help: "Gate dangerous shell/file/network/git tool calls before they execute. Catastrophic operations are always blocked while enabled.",
824
+ help: "Gate dangerous shell/file/network/git tool calls before they execute. Off by default. Catastrophic operations are blocked only while this is enabled.",
825
825
  },
826
826
  "interceptor.actionGuard.enforce": {
827
827
  label: "Enforce Action Guard",
@@ -3003,7 +3003,7 @@ export default {
3003
3003
  // so the status line reflects what before_tool_call will actually do.
3004
3004
  const rawInterceptor = cfg.interceptor;
3005
3005
  const guardCfg = {
3006
- ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] }),
3006
+ ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] }),
3007
3007
  ...(rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.actionGuard ?? {} : {}),
3008
3008
  };
3009
3009
  const interceptorOn = (rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.enabled : undefined) ?? DEFAULT_INTERCEPTOR_CONFIG.enabled;
@@ -3079,7 +3079,7 @@ export default {
3079
3079
  enabled: rawInterceptorConfig.enabled ?? DEFAULT_INTERCEPTOR_CONFIG.enabled,
3080
3080
  severityActions: { ...DEFAULT_INTERCEPTOR_CONFIG.severityActions, ...rawInterceptorConfig.severityActions },
3081
3081
  failurePolicy: { ...DEFAULT_INTERCEPTOR_CONFIG.failurePolicy, ...rawInterceptorConfig.failurePolicy },
3082
- actionGuard: { ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] }), ...(rawInterceptorConfig.actionGuard ?? {}) },
3082
+ actionGuard: { ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] }), ...(rawInterceptorConfig.actionGuard ?? {}) },
3083
3083
  } : {}),
3084
3084
  logger: { info: api.logger?.info ?? console.log, warn: api.logger?.warn ?? console.warn },
3085
3085
  };
@@ -33,13 +33,11 @@ const DEFAULT_CONFIG = {
33
33
  high: 'deny',
34
34
  critical: 'deny',
35
35
  },
36
- // Action Guard on by default: catastrophic ops are blocked out of the box;
37
- // recognised-dangerous ops are ENFORCED by default (P1/WS1) — attended → prompt,
38
- // unattended → fail closed on failurePolicy. Populate `autoApprove` per agent to
39
- // pre-approve the dangerous ops it legitimately needs unattended; set
40
- // `enforce:false` to opt back down to warn-and-allow.
36
+ // Action Guard OFF by default: false-card storms on live OpenClaw exec
37
+ // bags made default-on an uninstall risk. Catastrophic gating is also off
38
+ // until the operator signs `shieldcortex config --action-guard-enable`.
41
39
  actionGuard: {
42
- enabled: true,
40
+ enabled: false,
43
41
  enforce: true,
44
42
  autoApprove: [],
45
43
  auditAllows: true,
@@ -151,6 +149,22 @@ export function formatApprovalPrompt(input) {
151
149
  '[Approve] [Deny]',
152
150
  ].join('\n');
153
151
  }
152
+ /**
153
+ * The guard could not CLOSE this bag: the #412 tool-input schema rejected it,
154
+ * or the command-evidence walk ran out of budget before it had read all of it.
155
+ * Either way the call was never fully scanned, so no operator widening may
156
+ * apply to it — see `unscannedBlock` at the enforcement site.
157
+ *
158
+ * Read off the guard's own reason codes (`invalid_tool_input` /
159
+ * `invalid-tool-input`) rather than the decision tier, so it stays true
160
+ * whichever door the core decides this class deserves. Mirrored in
161
+ * `scripts/pre-tool-hook.mjs` (`isSchemaInvalid`) — the two enforcement
162
+ * surfaces must agree, and parity is asserted by the plane gate.
163
+ */
164
+ function isSchemaInvalid(v) {
165
+ return v.action === 'invalid_tool_input'
166
+ || (Array.isArray(v.signals) && v.signals.includes('invalid-tool-input'));
167
+ }
154
168
  // --- WS2 fail-closed fallback (guard load/eval failure) ---
155
169
  // Deliberately DUPLICATED from tool-action-guard.ts's CATASTROPHIC list, not
156
170
  // imported — this file already avoids a compile-time dependency on the main
@@ -534,7 +548,7 @@ export function createInterceptor(config, pipeline, options) {
534
548
  const bindAudit = options?.bindAudit;
535
549
  /** Args of the in-flight tool call — used only to mint #224 actionKey. */
536
550
  let lastCallArgs;
537
- const actionGuardCfg = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
551
+ const actionGuardCfg = config.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] };
538
552
  const evaluateToolCall = options?.evaluateToolCall;
539
553
  const broker = options?.broker;
540
554
  // The judge rides the operator's own model pool, so its calls are their cost
@@ -881,23 +895,70 @@ export function createInterceptor(config, pipeline, options) {
881
895
  v = { ...v, decision: esc.decision, reason: `${v.reason} — ESCALATED by tainted session: ${taint.reason}` };
882
896
  }
883
897
  }
898
+ // ── Native contract drift observation ────────────────────────────────
899
+ // A reviewed host contract grew fields ShieldCortex does not read. The
900
+ // guard already dropped them before nested validation and before any
901
+ // extractor, so nothing here can change the verdict — this is the record
902
+ // that the drop HAPPENED, which is the only way an operator learns a host
903
+ // schema moved without a card storm telling them. It rides on the call's
904
+ // own outcome rather than minting a row of its own, so a drifted benign
905
+ // allow stays a single row and volume discipline holds. `auditAllows:false`
906
+ // opts out with the rest of the recognised-allow stream.
907
+ const drift = v.contractDrift && v.contractDrift.droppedKeys.length > 0
908
+ ? { contractDrift: v.contractDrift }
909
+ : undefined;
910
+ if (v.decision === 'allow' && drift && actionGuardCfg.auditAllows !== false) {
911
+ const d = drift.contractDrift;
912
+ log.warn(`[shieldcortex] action-guard CONTRACT DRIFT ${context.toolName} (${d.contract}): dropped unread field(s) ${d.droppedKeys.join(', ')}${d.truncated ? ', …' : ''}`);
913
+ }
884
914
  if (v.decision === 'allow') {
885
915
  // Issue #95: a RECOGNISED allow (the guard evaluated a known operation
886
916
  // family and let it through — severity above benign) leaves an audit
887
917
  // entry, so forensics can distinguish "scanned & allowed" from "never
888
918
  // scanned". Benign allows stay unaudited by design (volume discipline);
889
919
  // `actionGuard.auditAllows: false` opts the recognised entries off too.
890
- if (v.severity !== 'benign' && actionGuardCfg.auditAllows !== false) {
891
- const allowPreview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
892
- emitAudit({ ...guardAuditBase(context.toolName, v, allowPreview), action: 'allow', outcome: 'allowed' });
920
+ if (actionGuardCfg.auditAllows !== false) {
921
+ if (v.severity !== 'benign') {
922
+ const allowPreview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
923
+ emitAudit({ ...guardAuditBase(context.toolName, v, allowPreview), ...(drift ?? {}), action: 'allow', outcome: 'allowed' });
924
+ }
925
+ else if (drift) {
926
+ // A benign allow is normally unaudited — but drift is the one thing
927
+ // about it worth keeping, so it rides on ONE row of its own rather
928
+ // than doubling the recognised-allow row above. The preview is the
929
+ // tool name and nothing else: a drifted field may hold a prompt or a
930
+ // token, and unlike `summariseToolArgs` this row must never be a
931
+ // channel for a value the guard just refused to read.
932
+ emitAudit({
933
+ ...guardAuditBase(context.toolName, v, `${context.toolName} :: contract-drift`),
934
+ ...drift,
935
+ action: 'allow',
936
+ outcome: 'allowed',
937
+ });
938
+ }
893
939
  }
894
940
  return;
895
941
  }
896
942
  const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
897
- const base = { ...guardAuditBase(context.toolName, v, preview), ...(escalation ? { escalated: escalation } : {}) };
943
+ const base = { ...guardAuditBase(context.toolName, v, preview), ...(escalation ? { escalated: escalation } : {}), ...(drift ?? {}) };
898
944
  const severity = v.severity === 'catastrophic' ? 'critical' : 'high';
899
945
  // Catastrophic / exfil — hard block, always enforced when the guard is enabled.
900
- if (v.decision === 'block') {
946
+ // #436: the door-less throw is for that tier only. A schema rejection falls
947
+ // through to requireApproval so the operator can still say yes. autoApprove
948
+ // and enforce:false must not widen an unscanned call — they skip below.
949
+ const terminalBlock = v.decision === 'block'
950
+ && (v.severity === 'catastrophic' || v.severity === 'critical');
951
+ // Derived from the guard's OWN schema signal, not from `decision`. The core
952
+ // answers a scanned-clean schema rejection with `require_approval` (so all
953
+ // three planes say the same word about the same call), which means
954
+ // `decision === 'block'` no longer identifies the class. Keying off the
955
+ // decision here would silently re-open exactly what #436 closed:
956
+ // `{command:'…', evil:'…'}` running unscanned on an `enforce:false` host,
957
+ // and `autoApprove: ['unknown-keys']` becoming a blanket bypass of the #412
958
+ // closed schema. The residual `decision === 'block' && !terminalBlock` arm
959
+ // is kept for any sub-catastrophic block a future rule mints.
960
+ const unscannedBlock = isSchemaInvalid(v) || (v.decision === 'block' && !terminalBlock);
961
+ if (terminalBlock) {
901
962
  // #227: release any lease this call minted early — a blocked action must
902
963
  // not leave a hold on that scope (self-heals at TTL if release fails).
903
964
  if (leaseGate?.acquired) {
@@ -919,7 +980,7 @@ export function createInterceptor(config, pipeline, options) {
919
980
  // legitimate dangerous work. It NEVER applies to catastrophic ops — those
920
981
  // hard-block above, before this branch is reached.
921
982
  const autoApprove = actionGuardCfg.autoApprove ?? [];
922
- if (autoApprove.length > 0) {
983
+ if (autoApprove.length > 0 && !unscannedBlock) {
923
984
  const hay = [v.family, v.action, ...v.signals].map(s => String(s).toLowerCase());
924
985
  const matched = autoApprove.some(a => {
925
986
  const n = a.toLowerCase();
@@ -932,7 +993,8 @@ export function createInterceptor(config, pipeline, options) {
932
993
  }
933
994
  // require_approval — ENFORCED by default (P1/WS1). `enforce:false` opts back
934
995
  // down to warn-and-allow (advisory) for operators who want the old behaviour.
935
- if (!actionGuardCfg.enforce) {
996
+ // #436: an unscanned schema rejection must not become an advisory allow.
997
+ if (!actionGuardCfg.enforce && !unscannedBlock) {
936
998
  log.warn(`[shieldcortex] ⚠️ Action Guard: ${context.toolName} — ${v.reason}`);
937
999
  emitAudit({ ...base, action: 'warn', outcome: 'warned' });
938
1000
  return;
@@ -953,7 +1015,7 @@ export function createInterceptor(config, pipeline, options) {
953
1015
  log.warn(`[shieldcortex] approval broker HARDENED ${context.toolName} to a denial: ${brokered.reason}`);
954
1016
  throw new Error(`ShieldCortex: tool call blocked — ${brokered.reason}`);
955
1017
  }
956
- if (brokered?.outcome === 'pre_clear') {
1018
+ if (brokered?.outcome === 'pre_clear' && !unscannedBlock) {
957
1019
  // Reversible, on-host, in-context, judge-confident: proceed without
958
1020
  // waiting. Loud on purpose — a release nobody approved must never be a
959
1021
  // silent one, because the audit row is the only thing that will ever tell
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.54.13",
3
+ "version": "4.54.15",
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,
@@ -304,7 +304,7 @@
304
304
  "properties": {
305
305
  "enabled": {
306
306
  "type": "boolean",
307
- "default": true
307
+ "default": false
308
308
  },
309
309
  "enforce": {
310
310
  "type": "boolean",
@@ -427,7 +427,7 @@
427
427
  "properties": {
428
428
  "enabled": {
429
429
  "type": "boolean",
430
- "default": true
430
+ "default": false
431
431
  },
432
432
  "enforce": {
433
433
  "type": "boolean",
package/index.ts CHANGED
@@ -1164,7 +1164,7 @@ const PLUGIN_CONFIG_UI_HINTS = {
1164
1164
  },
1165
1165
  "interceptor.actionGuard.enabled": {
1166
1166
  label: "Action Guard",
1167
- help: "Gate dangerous shell/file/network/git tool calls before they execute. Catastrophic operations are always blocked while enabled.",
1167
+ help: "Gate dangerous shell/file/network/git tool calls before they execute. Off by default. Catastrophic operations are blocked only while this is enabled.",
1168
1168
  },
1169
1169
  "interceptor.actionGuard.enforce": {
1170
1170
  label: "Enforce Action Guard",
@@ -3565,7 +3565,7 @@ export default {
3565
3565
  // so the status line reflects what before_tool_call will actually do.
3566
3566
  const rawInterceptor = cfg.interceptor;
3567
3567
  const guardCfg = {
3568
- ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] }),
3568
+ ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] }),
3569
3569
  ...(rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.actionGuard ?? {} : {}),
3570
3570
  };
3571
3571
  const interceptorOn = (rawInterceptor && typeof rawInterceptor === 'object' ? rawInterceptor.enabled : undefined) ?? DEFAULT_INTERCEPTOR_CONFIG.enabled;
@@ -3643,7 +3643,7 @@ export default {
3643
3643
  enabled: rawInterceptorConfig.enabled ?? DEFAULT_INTERCEPTOR_CONFIG.enabled,
3644
3644
  severityActions: { ...DEFAULT_INTERCEPTOR_CONFIG.severityActions, ...rawInterceptorConfig.severityActions },
3645
3645
  failurePolicy: { ...DEFAULT_INTERCEPTOR_CONFIG.failurePolicy, ...rawInterceptorConfig.failurePolicy },
3646
- actionGuard: { ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] }), ...(rawInterceptorConfig.actionGuard ?? {}) },
3646
+ actionGuard: { ...(DEFAULT_INTERCEPTOR_CONFIG.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] }), ...(rawInterceptorConfig.actionGuard ?? {}) },
3647
3647
  } : {}),
3648
3648
  logger: { info: api.logger?.info ?? console.log, warn: (api.logger as any)?.warn ?? console.warn },
3649
3649
  };
package/interceptor.ts CHANGED
@@ -46,6 +46,14 @@ export interface ActionGuardConfig {
46
46
  /** Structural shape of a Tool Action Guard verdict (kept local to avoid a
47
47
  * compile-time dependency on the main package across the plugin build boundary;
48
48
  * the real `evaluateToolCall` from `shieldcortex/defence` is compatible). */
49
+ /** A reviewed native contract grew fields ShieldCortex does not read. Names
50
+ * only — the guard drops the values before any scanner sees them. */
51
+ export interface ContractDriftLike {
52
+ contract: string;
53
+ droppedKeys: string[];
54
+ truncated?: boolean;
55
+ }
56
+
49
57
  export interface ToolGuardVerdictLike {
50
58
  decision: 'allow' | 'require_approval' | 'block';
51
59
  severity: 'benign' | 'sensitive' | 'dangerous' | 'catastrophic' | string;
@@ -53,6 +61,8 @@ export interface ToolGuardVerdictLike {
53
61
  action: string;
54
62
  reason: string;
55
63
  signals: string[];
64
+ /** Present only when a reviewed contract drifted. Never a verdict input. */
65
+ contractDrift?: ContractDriftLike;
56
66
  /** Rule → matched-span evidence behind `signals` (issue #192).
57
67
  * #184: optional source/line/chain when the match came from folded script. */
58
68
  matches?: Array<{
@@ -277,6 +287,15 @@ export interface InterceptAuditEntry {
277
287
  escalated?: { by: 'session-taint'; from: string; to: string; reason: string };
278
288
  /** Files the reviewed-script allowlist exempted from folding (#189). */
279
289
  reviewedScripts?: string[];
290
+ /**
291
+ * Native contract drift: a reviewed exact-name host contract carried fields
292
+ * ShieldCortex does not read, which the guard dropped before validation and
293
+ * before any extractor. Key NAMES only, bounded — an operator can see THAT a
294
+ * host schema moved and which fields moved, without the row ever carrying a
295
+ * value from them. Advisory: this rides on the call's own outcome and never
296
+ * gates, denies, or mints a card.
297
+ */
298
+ contractDrift?: ContractDriftLike;
280
299
  /** #260 — plane origin so the session-guard summariser can find this row. */
281
300
  origin?: 'openclaw-interceptor';
282
301
  sessionKey?: string;
@@ -316,13 +335,11 @@ const DEFAULT_CONFIG: InterceptorConfig = {
316
335
  high: 'deny',
317
336
  critical: 'deny',
318
337
  },
319
- // Action Guard on by default: catastrophic ops are blocked out of the box;
320
- // recognised-dangerous ops are ENFORCED by default (P1/WS1) — attended → prompt,
321
- // unattended → fail closed on failurePolicy. Populate `autoApprove` per agent to
322
- // pre-approve the dangerous ops it legitimately needs unattended; set
323
- // `enforce:false` to opt back down to warn-and-allow.
338
+ // Action Guard OFF by default: false-card storms on live OpenClaw exec
339
+ // bags made default-on an uninstall risk. Catastrophic gating is also off
340
+ // until the operator signs `shieldcortex config --action-guard-enable`.
324
341
  actionGuard: {
325
- enabled: true,
342
+ enabled: false,
326
343
  enforce: true,
327
344
  autoApprove: [],
328
345
  auditAllows: true,
@@ -466,6 +483,23 @@ export function formatApprovalPrompt(input: ApprovalPromptInput): string {
466
483
  ].join('\n');
467
484
  }
468
485
 
486
+ /**
487
+ * The guard could not CLOSE this bag: the #412 tool-input schema rejected it,
488
+ * or the command-evidence walk ran out of budget before it had read all of it.
489
+ * Either way the call was never fully scanned, so no operator widening may
490
+ * apply to it — see `unscannedBlock` at the enforcement site.
491
+ *
492
+ * Read off the guard's own reason codes (`invalid_tool_input` /
493
+ * `invalid-tool-input`) rather than the decision tier, so it stays true
494
+ * whichever door the core decides this class deserves. Mirrored in
495
+ * `scripts/pre-tool-hook.mjs` (`isSchemaInvalid`) — the two enforcement
496
+ * surfaces must agree, and parity is asserted by the plane gate.
497
+ */
498
+ function isSchemaInvalid(v: ToolGuardVerdictLike): boolean {
499
+ return v.action === 'invalid_tool_input'
500
+ || (Array.isArray(v.signals) && v.signals.includes('invalid-tool-input'));
501
+ }
502
+
469
503
  // --- WS2 fail-closed fallback (guard load/eval failure) ---
470
504
  // Deliberately DUPLICATED from tool-action-guard.ts's CATASTROPHIC list, not
471
505
  // imported — this file already avoids a compile-time dependency on the main
@@ -946,7 +980,7 @@ export function createInterceptor(
946
980
  const bindAudit = options?.bindAudit;
947
981
  /** Args of the in-flight tool call — used only to mint #224 actionKey. */
948
982
  let lastCallArgs: Record<string, unknown> | undefined;
949
- const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled: true, enforce: true, autoApprove: [] };
983
+ const actionGuardCfg: ActionGuardConfig = config.actionGuard ?? { enabled: false, enforce: true, autoApprove: [] };
950
984
  const evaluateToolCall = options?.evaluateToolCall;
951
985
  const broker = options?.broker;
952
986
  // The judge rides the operator's own model pool, so its calls are their cost
@@ -1304,25 +1338,74 @@ export function createInterceptor(
1304
1338
  }
1305
1339
  }
1306
1340
 
1341
+ // ── Native contract drift observation ────────────────────────────────
1342
+ // A reviewed host contract grew fields ShieldCortex does not read. The
1343
+ // guard already dropped them before nested validation and before any
1344
+ // extractor, so nothing here can change the verdict — this is the record
1345
+ // that the drop HAPPENED, which is the only way an operator learns a host
1346
+ // schema moved without a card storm telling them. It rides on the call's
1347
+ // own outcome rather than minting a row of its own, so a drifted benign
1348
+ // allow stays a single row and volume discipline holds. `auditAllows:false`
1349
+ // opts out with the rest of the recognised-allow stream.
1350
+ const drift = v.contractDrift && v.contractDrift.droppedKeys.length > 0
1351
+ ? { contractDrift: v.contractDrift }
1352
+ : undefined;
1353
+ if (v.decision === 'allow' && drift && actionGuardCfg.auditAllows !== false) {
1354
+ const d = drift.contractDrift;
1355
+ log.warn(
1356
+ `[shieldcortex] action-guard CONTRACT DRIFT ${context.toolName} (${d.contract}): dropped unread field(s) ${d.droppedKeys.join(', ')}${d.truncated ? ', …' : ''}`,
1357
+ );
1358
+ }
1359
+
1307
1360
  if (v.decision === 'allow') {
1308
1361
  // Issue #95: a RECOGNISED allow (the guard evaluated a known operation
1309
1362
  // family and let it through — severity above benign) leaves an audit
1310
1363
  // entry, so forensics can distinguish "scanned & allowed" from "never
1311
1364
  // scanned". Benign allows stay unaudited by design (volume discipline);
1312
1365
  // `actionGuard.auditAllows: false` opts the recognised entries off too.
1313
- if (v.severity !== 'benign' && actionGuardCfg.auditAllows !== false) {
1314
- const allowPreview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
1315
- emitAudit({ ...guardAuditBase(context.toolName, v, allowPreview), action: 'allow', outcome: 'allowed' });
1366
+ if (actionGuardCfg.auditAllows !== false) {
1367
+ if (v.severity !== 'benign') {
1368
+ const allowPreview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
1369
+ emitAudit({ ...guardAuditBase(context.toolName, v, allowPreview), ...(drift ?? {}), action: 'allow', outcome: 'allowed' });
1370
+ } else if (drift) {
1371
+ // A benign allow is normally unaudited — but drift is the one thing
1372
+ // about it worth keeping, so it rides on ONE row of its own rather
1373
+ // than doubling the recognised-allow row above. The preview is the
1374
+ // tool name and nothing else: a drifted field may hold a prompt or a
1375
+ // token, and unlike `summariseToolArgs` this row must never be a
1376
+ // channel for a value the guard just refused to read.
1377
+ emitAudit({
1378
+ ...guardAuditBase(context.toolName, v, `${context.toolName} :: contract-drift`),
1379
+ ...drift,
1380
+ action: 'allow',
1381
+ outcome: 'allowed',
1382
+ });
1383
+ }
1316
1384
  }
1317
1385
  return;
1318
1386
  }
1319
1387
 
1320
1388
  const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
1321
- const base = { ...guardAuditBase(context.toolName, v, preview), ...(escalation ? { escalated: escalation } : {}) };
1389
+ const base = { ...guardAuditBase(context.toolName, v, preview), ...(escalation ? { escalated: escalation } : {}), ...(drift ?? {}) };
1322
1390
  const severity: Severity = v.severity === 'catastrophic' ? 'critical' : 'high';
1323
1391
 
1324
1392
  // Catastrophic / exfil — hard block, always enforced when the guard is enabled.
1325
- if (v.decision === 'block') {
1393
+ // #436: the door-less throw is for that tier only. A schema rejection falls
1394
+ // through to requireApproval so the operator can still say yes. autoApprove
1395
+ // and enforce:false must not widen an unscanned call — they skip below.
1396
+ const terminalBlock = v.decision === 'block'
1397
+ && (v.severity === 'catastrophic' || v.severity === 'critical');
1398
+ // Derived from the guard's OWN schema signal, not from `decision`. The core
1399
+ // answers a scanned-clean schema rejection with `require_approval` (so all
1400
+ // three planes say the same word about the same call), which means
1401
+ // `decision === 'block'` no longer identifies the class. Keying off the
1402
+ // decision here would silently re-open exactly what #436 closed:
1403
+ // `{command:'…', evil:'…'}` running unscanned on an `enforce:false` host,
1404
+ // and `autoApprove: ['unknown-keys']` becoming a blanket bypass of the #412
1405
+ // closed schema. The residual `decision === 'block' && !terminalBlock` arm
1406
+ // is kept for any sub-catastrophic block a future rule mints.
1407
+ const unscannedBlock = isSchemaInvalid(v) || (v.decision === 'block' && !terminalBlock);
1408
+ if (terminalBlock) {
1326
1409
  // #227: release any lease this call minted early — a blocked action must
1327
1410
  // not leave a hold on that scope (self-heals at TTL if release fails).
1328
1411
  if (leaseGate?.acquired) {
@@ -1342,7 +1425,7 @@ export function createInterceptor(
1342
1425
  // legitimate dangerous work. It NEVER applies to catastrophic ops — those
1343
1426
  // hard-block above, before this branch is reached.
1344
1427
  const autoApprove = actionGuardCfg.autoApprove ?? [];
1345
- if (autoApprove.length > 0) {
1428
+ if (autoApprove.length > 0 && !unscannedBlock) {
1346
1429
  const hay = [v.family, v.action, ...v.signals].map(s => String(s).toLowerCase());
1347
1430
  const matched = autoApprove.some(a => {
1348
1431
  const n = a.toLowerCase();
@@ -1356,7 +1439,8 @@ export function createInterceptor(
1356
1439
 
1357
1440
  // require_approval — ENFORCED by default (P1/WS1). `enforce:false` opts back
1358
1441
  // down to warn-and-allow (advisory) for operators who want the old behaviour.
1359
- if (!actionGuardCfg.enforce) {
1442
+ // #436: an unscanned schema rejection must not become an advisory allow.
1443
+ if (!actionGuardCfg.enforce && !unscannedBlock) {
1360
1444
  log.warn(`[shieldcortex] ⚠️ Action Guard: ${context.toolName} — ${v.reason}`);
1361
1445
  emitAudit({ ...base, action: 'warn', outcome: 'warned' });
1362
1446
  return;
@@ -1380,7 +1464,7 @@ export function createInterceptor(
1380
1464
  throw new Error(`ShieldCortex: tool call blocked — ${brokered.reason}`);
1381
1465
  }
1382
1466
 
1383
- if (brokered?.outcome === 'pre_clear') {
1467
+ if (brokered?.outcome === 'pre_clear' && !unscannedBlock) {
1384
1468
  // Reversible, on-host, in-context, judge-confident: proceed without
1385
1469
  // waiting. Loud on purpose — a release nobody approved must never be a
1386
1470
  // silent one, because the audit row is the only thing that will ever tell
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.54.13",
3
+ "version": "4.54.15",
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,
@@ -304,7 +304,7 @@
304
304
  "properties": {
305
305
  "enabled": {
306
306
  "type": "boolean",
307
- "default": true
307
+ "default": false
308
308
  },
309
309
  "enforce": {
310
310
  "type": "boolean",
@@ -427,7 +427,7 @@
427
427
  "properties": {
428
428
  "enabled": {
429
429
  "type": "boolean",
430
- "default": true
430
+ "default": false
431
431
  },
432
432
  "enforce": {
433
433
  "type": "boolean",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.54.13",
3
+ "version": "4.54.15",
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",