@drakon-systems/shieldcortex-realtime 4.47.8 → 4.47.10

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.
@@ -156,7 +156,7 @@ export function formatApprovalPrompt(input) {
156
156
  // itself break unattended agents/cron, the outcome ShieldCortex exists to
157
157
  // prevent. Mirrored in scripts/pre-tool-hook.mjs for the Claude Code surface.
158
158
  const FALLBACK_CATASTROPHIC_PATTERNS = [
159
- /\brm\b[^|;&\n]*?(?:-\w*r\w*f\w*|-\w*f\w*r\w*|(?=[^|;&\n]*--recursive)(?=[^|;&\n]*--force))/i,
159
+ /\brm\b[^|;&\n]*?(?:(?<![\w.\/-])-\w*r\w*f\w*|(?<![\w.\/-])-\w*f\w*r\w*|(?=[^|;&\n]*--recursive)(?=[^|;&\n]*--force))/i,
160
160
  /\brm\b[^|;&\n]*\s(?:-\w+\s+)*(?:\/|~|\$HOME|\/\*|\*|\.\/\*)(?:\s|$)/i,
161
161
  /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:?\s*&?\s*\}\s*;\s*:/,
162
162
  /\bmkfs(\.\w+)?\b/i,
@@ -171,11 +171,49 @@ const FALLBACK_CATASTROPHIC_PATTERNS = [
171
171
  /\b(?:curl|wget|fetch)\b[^|\n]*\|[^\n]*\bpython\d?\b[^\n]*\s-m\s*(?:code|pty|pdb)(?![\w.])/i,
172
172
  /\bch(?:mod|own)\b[^|;&\n]*(?:-\w*R\w*|--recursive)\b[^|;&\n]*\s\/(?:\s|$)/i,
173
173
  ];
174
+ // WS2 dangerous-tier fallback (issue #59). Ports EVERY signal in
175
+ // tool-action-guard.ts's DANGEROUS array — a drift test
176
+ // (ws2-gate-degraded-integration-59) fails if the guard gains a DANGEROUS
177
+ // signal this list doesn't cover, so "kept in sync" is enforced, not just
178
+ // claimed. Used ONLY when the real guard can't scan: a recognised-dangerous
179
+ // shape routes through `failurePolicy` exactly as an unattended real verdict
180
+ // would, instead of the pre-#59 fail-OPEN. Copies the real (already anti-ReDoS,
181
+ // FP-narrowed) patterns verbatim, so read-only forms — `crontab -l`, `npm ls
182
+ // -g`, `--global-style`, shred use/mention — still pass. The one guard shape
183
+ // NOT here is bare `npx`/`bunx`: those are gated CONDITIONALLY by
184
+ // `isGatedNpxBunx` (shape-based, #96), not by a DANGEROUS pattern, and a blunt
185
+ // fallback matching them would over-gate `npx tsc`; `uvx`/`dlx` (unconditional)
186
+ // ARE covered. Mirrored in scripts/pre-tool-hook.mjs + hermes/sc_client.py.
187
+ const FALLBACK_DANGEROUS_PATTERNS = [
188
+ { re: /\brm\b|\bunlink\b|\brmdir\b|(?:(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?|\bxargs\s+(?:-{1,2}\S+\s+)*|-exec\s+)shred\b/i, signal: 'file-delete' },
189
+ { re: /\bsudo\b|\bdoas\b|\bsu\s/i, signal: 'privilege-escalation' },
190
+ { re: /\bgit\b[^|\n]*\bpush\b[^|\n]*(--force\b|-f\b|\+)/i, signal: 'git-force-push' },
191
+ { re: /\bgit\b[^|\n]*\b(branch\s+-D|push\b[^|\n]*--delete|push\b[^|\n]*\s:)/i, signal: 'git-delete-branch' },
192
+ { re: /\b(systemctl|service)\b[^|\n]*\b(stop|disable|mask)\b|\b(kill|pkill|killall)\b/i, signal: 'stop-process-or-service' },
193
+ { re: /\b(iptables|ufw|nft|netplan|firewall-cmd)\b/i, signal: 'modify-network-firewall' },
194
+ { re: /\b(?:apt|apt-get|yum|dnf|brew|pip|pip3|gem|cargo)\b[^|\n]*\b(?:install|add)\b/i, signal: 'install-package' },
195
+ { re: /\b(?:npm|yarn|pnpm|bun)\b(?=[^|;&\n]*(?:\s['"]?-g\b['"]?|--global(?![\w-])|\bglobal\s+add\b))(?=[^|;&\n]*\s(?:install|add)(?=\s|$|[|;&\n]))|\b(?:npm|pnpm|bun)\s+(?:i(?:n(?:s(?:t(?:a(?:ll?)?)?)?)?)?|isnt(?:all)?)\b[^|;&\n]*(?:\s['"]?-g\b['"]?|--global(?![\w-]))/i, signal: 'install-package-global' },
196
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:(?:env|nohup|time|stdbuf|nice)\b(?:\s+(?:-{1,2}\S+|\w+=\S*|\d+))*\s+)*(?:sudo\s+)?(?:crontab\b(?!\s+-l\b)|at\b(?!\s+-l\b)(?!\s*$))|\/etc\/cron|\bsystemd-run\b[^|;&\n]*--on-(?:calendar|active|boot|startup|unit-active|unit-inactive)\b/i, signal: 'modify-scheduler' },
197
+ { re: /\bdd\b[^|;&\n]*\bof=/i, signal: 'dd-overwrite' },
198
+ { re: /\bch(?:mod|own)\b[^|;&\n]*(?:-\w*R\w*|--recursive)\b[^|;&\n]*\s\/(?:etc|usr|var|home|bin|sbin|boot|lib|lib64|opt|root)(?:\/\*?)?(?:\s|$)/i, signal: 'recursive-perms-system-dir' },
199
+ { re: /\btruncate\b[^|;&\n]*(?:-s\s*0\b|--size(?:=|\s+)0\b)/i, signal: 'truncate-to-zero' },
200
+ { re: /\bhistory\s+-c\b|\.bash_history|truncate\b[^|\n]*\.log/i, signal: 'wipe-history-or-logs' },
201
+ { re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
202
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
203
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
204
+ { re: /\b(?:base64|openssl|xxd|cat|http)\b[^\n|]*\|(?:[^\n|]*\|)*\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:bash|sh|zsh|ksh|python\d?|perl|ruby|node)\b(?:\s+-)?\s*(?:[;&|\n]|$)/i, signal: 'decode-pipe-to-shell' },
205
+ ];
174
206
  const FALLBACK_SURFACE_KEYS = [
175
207
  'command', 'cmd', 'script', 'code', 'input', 'shell', 'run',
176
208
  'path', 'file_path', 'filePath', 'file', 'target', 'destination', 'dir', 'directory',
177
209
  'url', 'uri', 'endpoint', 'href', 'host', 'to',
178
210
  ];
211
+ // The fallback is an outage-only blunt scanner; an UNBOUNDED scan over crafted
212
+ // input is a ReDoS vector (some ported guard patterns are O(n²) on pathological
213
+ // token runs like `git push push push …`). Dangerous/catastrophic shapes appear
214
+ // early in any real command, so cap the scanned surface — 4 KB is far beyond a
215
+ // real shell command. The real guard (no cap) still scans in full when it works.
216
+ const FALLBACK_SCAN_CAP = 4096;
179
217
  /** Same command/path/url field set tool-action-guard.ts extracts — narrow, not the whole args object. */
180
218
  function fallbackExecSurface(args) {
181
219
  const parts = [];
@@ -184,7 +222,7 @@ function fallbackExecSurface(args) {
184
222
  if (typeof v === 'string' && v.length > 0)
185
223
  parts.push(v);
186
224
  }
187
- return parts.join(' ');
225
+ return parts.join(' ').slice(0, FALLBACK_SCAN_CAP);
188
226
  }
189
227
  function fallbackCatastrophicMatch(args) {
190
228
  const text = fallbackExecSurface(args);
@@ -192,6 +230,17 @@ function fallbackCatastrophicMatch(args) {
192
230
  return false;
193
231
  return FALLBACK_CATASTROPHIC_PATTERNS.some(re => re.test(text));
194
232
  }
233
+ /** First matching dangerous signal for the WS2 fallback, or null (issue #59). */
234
+ function fallbackDangerousMatch(args) {
235
+ const text = fallbackExecSurface(args);
236
+ if (!text)
237
+ return null;
238
+ for (const { re, signal } of FALLBACK_DANGEROUS_PATTERNS) {
239
+ if (re.test(text))
240
+ return signal;
241
+ }
242
+ return null;
243
+ }
195
244
  /** One-line summary of tool args for audit previews (bounded, no secrets dumped). */
196
245
  export function summariseToolArgs(args) {
197
246
  if (!args)
@@ -311,27 +360,55 @@ export function createInterceptor(config, pipeline, options) {
311
360
  preview: preview.slice(0, 200), ts: new Date().toISOString(),
312
361
  };
313
362
  }
314
- // WS2 fail-closed path: when the real guard was never wired in or throws,
315
- // run the narrow fallback scan and DENY (throw) a catastrophic-looking
316
- // command instead of silently allowing it. The fallback recognising nothing
317
- // is not evidence the command is safe, only that it isn't one of the
318
- // handful of unambiguous shapes — everything else still falls through to
319
- // the pre-existing fail-OPEN log-and-allow.
363
+ // WS2 fail-closed path (issue #59): when the real guard was never wired in or
364
+ // throws, run the dependency-free fallback scan. Three tiers, so no dangerous
365
+ // op is ever silently allowed on a scan failure — and every could-not-scan
366
+ // decision leaves a `gate_degraded` audit row (ACTION_GUARD_FALLBACK marker),
367
+ // so forensics can distinguish "scanned & allowed" from "could not scan":
368
+ // 1. catastrophic → hard deny, always (ignores enforce:false).
369
+ // 2. dangerous → route through `failurePolicy` exactly as an unattended
370
+ // real verdict would (deny by default); enforce:false
371
+ // opts down to advisory.
372
+ // 3. no match → benign/unknown: fail OPEN (a degraded guard must not
373
+ // wedge normal work) but leave a visible breadcrumb.
320
374
  function handleGuardUnavailable(context, reason) {
321
- if (!fallbackCatastrophicMatch(context.arguments)) {
322
- log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — allowing ${context.toolName}`);
375
+ const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`.slice(0, 200);
376
+ const degradedBase = {
377
+ type: 'intercept', tool: context.toolName,
378
+ firewallResult: 'ACTION_GUARD_FALLBACK', trustScore: 0, sensitivityLevel: 'INTERNAL',
379
+ fragmentationScore: null, pipelineDurationMs: 0, preview, ts: new Date().toISOString(),
380
+ };
381
+ // 1. Catastrophic — hard deny, always.
382
+ if (fallbackCatastrophicMatch(context.arguments)) {
383
+ emitAudit({
384
+ ...degradedBase, severity: 'critical', threats: ['fallback-scan'], anomalyScore: 1,
385
+ action: 'auto_deny', outcome: 'auto_denied',
386
+ });
387
+ log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback scan matched a catastrophic pattern — DENYING ${context.toolName} (fail-closed, WS2)`);
388
+ throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), fallback catastrophic scan matched`);
389
+ }
390
+ // 2. Dangerous — route through failurePolicy (the "can't obtain a verdict"
391
+ // policy; a degraded guard is precisely that). enforce:false → advisory.
392
+ const dangerousSignal = fallbackDangerousMatch(context.arguments);
393
+ if (dangerousSignal) {
394
+ const dBase = { ...degradedBase, severity: 'high', threats: ['fallback-scan', dangerousSignal], anomalyScore: 0.6 };
395
+ if (!actionGuardCfg.enforce) {
396
+ emitAudit({ ...dBase, action: 'gate_degraded', outcome: 'failure_allowed' });
397
+ log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — advisory (enforce:false), allowing dangerous ${context.toolName} [${dangerousSignal}]`);
398
+ return;
399
+ }
400
+ const failAction = config.failurePolicy.high;
401
+ emitAudit({ ...dBase, action: 'gate_degraded', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
402
+ if (failAction === 'deny') {
403
+ log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback matched a DANGEROUS op [${dangerousSignal}] — DENYING ${context.toolName} (fail-closed, failure policy: deny)`);
404
+ throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), dangerous fallback match [${dangerousSignal}], failure policy: deny`);
405
+ }
323
406
  return;
324
407
  }
325
- const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
326
- emitAudit({
327
- type: 'intercept', tool: context.toolName, severity: 'critical',
328
- firewallResult: 'ACTION_GUARD_FALLBACK', threats: ['fallback-scan'],
329
- anomalyScore: 1, trustScore: 0, sensitivityLevel: 'INTERNAL', fragmentationScore: null,
330
- pipelineDurationMs: 0, preview: preview.slice(0, 200), ts: new Date().toISOString(),
331
- action: 'auto_deny', outcome: 'auto_denied',
332
- });
333
- log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback scan matched a catastrophic pattern — DENYING ${context.toolName} (fail-closed, WS2)`);
334
- throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), fallback catastrophic scan matched`);
408
+ // 3. No match — benign/unknown. Fail open, but never silently: the
409
+ // gate_degraded breadcrumb makes the outage window auditable.
410
+ emitAudit({ ...degradedBase, severity: 'low', threats: ['fallback-scan'], anomalyScore: 0.1, action: 'gate_degraded', outcome: 'failure_allowed' });
411
+ log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — allowing ${context.toolName} (fallback matched nothing; fail-open)`);
335
412
  }
336
413
  // Action Guard: gates non-memory tool calls (shell / file / network / git).
337
414
  // This is what makes "Iron Dome protects what the agent DOES" true at runtime.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.8",
3
+ "version": "4.47.10",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
package/interceptor.ts CHANGED
@@ -69,7 +69,7 @@ export interface InterceptAuditEntry {
69
69
  sensitivityLevel: string; // from the pipeline result's sensitivity level
70
70
  fragmentationScore: number | null; // from the pipeline result's fragmentation score, or null
71
71
  pipelineDurationMs: number; // wall-clock ms around the runDefencePipeline call
72
- action: InterceptAction | 'auto_deny' | 'rate_limit' | 'allow';
72
+ action: InterceptAction | 'auto_deny' | 'rate_limit' | 'allow' | 'gate_degraded';
73
73
  outcome: 'approved' | 'denied' | 'auto_denied' | 'logged' | 'warned' | 'failure_allowed' | 'failure_denied' | 'allowed';
74
74
  preview: string;
75
75
  ts: string;
@@ -263,7 +263,7 @@ export function formatApprovalPrompt(input: ApprovalPromptInput): string {
263
263
  // itself break unattended agents/cron, the outcome ShieldCortex exists to
264
264
  // prevent. Mirrored in scripts/pre-tool-hook.mjs for the Claude Code surface.
265
265
  const FALLBACK_CATASTROPHIC_PATTERNS: RegExp[] = [
266
- /\brm\b[^|;&\n]*?(?:-\w*r\w*f\w*|-\w*f\w*r\w*|(?=[^|;&\n]*--recursive)(?=[^|;&\n]*--force))/i,
266
+ /\brm\b[^|;&\n]*?(?:(?<![\w.\/-])-\w*r\w*f\w*|(?<![\w.\/-])-\w*f\w*r\w*|(?=[^|;&\n]*--recursive)(?=[^|;&\n]*--force))/i,
267
267
  /\brm\b[^|;&\n]*\s(?:-\w+\s+)*(?:\/|~|\$HOME|\/\*|\*|\.\/\*)(?:\s|$)/i,
268
268
  /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:?\s*&?\s*\}\s*;\s*:/,
269
269
  /\bmkfs(\.\w+)?\b/i,
@@ -279,12 +279,52 @@ const FALLBACK_CATASTROPHIC_PATTERNS: RegExp[] = [
279
279
  /\bch(?:mod|own)\b[^|;&\n]*(?:-\w*R\w*|--recursive)\b[^|;&\n]*\s\/(?:\s|$)/i,
280
280
  ];
281
281
 
282
+ // WS2 dangerous-tier fallback (issue #59). Ports EVERY signal in
283
+ // tool-action-guard.ts's DANGEROUS array — a drift test
284
+ // (ws2-gate-degraded-integration-59) fails if the guard gains a DANGEROUS
285
+ // signal this list doesn't cover, so "kept in sync" is enforced, not just
286
+ // claimed. Used ONLY when the real guard can't scan: a recognised-dangerous
287
+ // shape routes through `failurePolicy` exactly as an unattended real verdict
288
+ // would, instead of the pre-#59 fail-OPEN. Copies the real (already anti-ReDoS,
289
+ // FP-narrowed) patterns verbatim, so read-only forms — `crontab -l`, `npm ls
290
+ // -g`, `--global-style`, shred use/mention — still pass. The one guard shape
291
+ // NOT here is bare `npx`/`bunx`: those are gated CONDITIONALLY by
292
+ // `isGatedNpxBunx` (shape-based, #96), not by a DANGEROUS pattern, and a blunt
293
+ // fallback matching them would over-gate `npx tsc`; `uvx`/`dlx` (unconditional)
294
+ // ARE covered. Mirrored in scripts/pre-tool-hook.mjs + hermes/sc_client.py.
295
+ const FALLBACK_DANGEROUS_PATTERNS: Array<{ re: RegExp; signal: string }> = [
296
+ { re: /\brm\b|\bunlink\b|\brmdir\b|(?:(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?|\bxargs\s+(?:-{1,2}\S+\s+)*|-exec\s+)shred\b/i, signal: 'file-delete' },
297
+ { re: /\bsudo\b|\bdoas\b|\bsu\s/i, signal: 'privilege-escalation' },
298
+ { re: /\bgit\b[^|\n]*\bpush\b[^|\n]*(--force\b|-f\b|\+)/i, signal: 'git-force-push' },
299
+ { re: /\bgit\b[^|\n]*\b(branch\s+-D|push\b[^|\n]*--delete|push\b[^|\n]*\s:)/i, signal: 'git-delete-branch' },
300
+ { re: /\b(systemctl|service)\b[^|\n]*\b(stop|disable|mask)\b|\b(kill|pkill|killall)\b/i, signal: 'stop-process-or-service' },
301
+ { re: /\b(iptables|ufw|nft|netplan|firewall-cmd)\b/i, signal: 'modify-network-firewall' },
302
+ { re: /\b(?:apt|apt-get|yum|dnf|brew|pip|pip3|gem|cargo)\b[^|\n]*\b(?:install|add)\b/i, signal: 'install-package' },
303
+ { re: /\b(?:npm|yarn|pnpm|bun)\b(?=[^|;&\n]*(?:\s['"]?-g\b['"]?|--global(?![\w-])|\bglobal\s+add\b))(?=[^|;&\n]*\s(?:install|add)(?=\s|$|[|;&\n]))|\b(?:npm|pnpm|bun)\s+(?:i(?:n(?:s(?:t(?:a(?:ll?)?)?)?)?)?|isnt(?:all)?)\b[^|;&\n]*(?:\s['"]?-g\b['"]?|--global(?![\w-]))/i, signal: 'install-package-global' },
304
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:(?:env|nohup|time|stdbuf|nice)\b(?:\s+(?:-{1,2}\S+|\w+=\S*|\d+))*\s+)*(?:sudo\s+)?(?:crontab\b(?!\s+-l\b)|at\b(?!\s+-l\b)(?!\s*$))|\/etc\/cron|\bsystemd-run\b[^|;&\n]*--on-(?:calendar|active|boot|startup|unit-active|unit-inactive)\b/i, signal: 'modify-scheduler' },
305
+ { re: /\bdd\b[^|;&\n]*\bof=/i, signal: 'dd-overwrite' },
306
+ { re: /\bch(?:mod|own)\b[^|;&\n]*(?:-\w*R\w*|--recursive)\b[^|;&\n]*\s\/(?:etc|usr|var|home|bin|sbin|boot|lib|lib64|opt|root)(?:\/\*?)?(?:\s|$)/i, signal: 'recursive-perms-system-dir' },
307
+ { re: /\btruncate\b[^|;&\n]*(?:-s\s*0\b|--size(?:=|\s+)0\b)/i, signal: 'truncate-to-zero' },
308
+ { re: /\bhistory\s+-c\b|\.bash_history|truncate\b[^|\n]*\.log/i, signal: 'wipe-history-or-logs' },
309
+ { re: /\/etc\/(passwd|shadow|sudoers)|~\/\.ssh|id_rsa|\.aws\/credentials|\.env\b/i, signal: 'touch-sensitive-path' },
310
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?uvx\b/i, signal: 'registry-code-exec' },
311
+ { re: /(?:^|[;&|(\n]|\$\()\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:pnpm|yarn)\b[^|;&\n]*\bdlx\b/i, signal: 'registry-code-exec' },
312
+ { re: /\b(?:base64|openssl|xxd|cat|http)\b[^\n|]*\|(?:[^\n|]*\|)*\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(?:bash|sh|zsh|ksh|python\d?|perl|ruby|node)\b(?:\s+-)?\s*(?:[;&|\n]|$)/i, signal: 'decode-pipe-to-shell' },
313
+ ];
314
+
282
315
  const FALLBACK_SURFACE_KEYS = [
283
316
  'command', 'cmd', 'script', 'code', 'input', 'shell', 'run',
284
317
  'path', 'file_path', 'filePath', 'file', 'target', 'destination', 'dir', 'directory',
285
318
  'url', 'uri', 'endpoint', 'href', 'host', 'to',
286
319
  ];
287
320
 
321
+ // The fallback is an outage-only blunt scanner; an UNBOUNDED scan over crafted
322
+ // input is a ReDoS vector (some ported guard patterns are O(n²) on pathological
323
+ // token runs like `git push push push …`). Dangerous/catastrophic shapes appear
324
+ // early in any real command, so cap the scanned surface — 4 KB is far beyond a
325
+ // real shell command. The real guard (no cap) still scans in full when it works.
326
+ const FALLBACK_SCAN_CAP = 4096;
327
+
288
328
  /** Same command/path/url field set tool-action-guard.ts extracts — narrow, not the whole args object. */
289
329
  function fallbackExecSurface(args: Record<string, unknown> | undefined): string {
290
330
  const parts: string[] = [];
@@ -292,7 +332,7 @@ function fallbackExecSurface(args: Record<string, unknown> | undefined): string
292
332
  const v = args?.[k];
293
333
  if (typeof v === 'string' && v.length > 0) parts.push(v);
294
334
  }
295
- return parts.join(' ');
335
+ return parts.join(' ').slice(0, FALLBACK_SCAN_CAP);
296
336
  }
297
337
 
298
338
  function fallbackCatastrophicMatch(args: Record<string, unknown> | undefined): boolean {
@@ -301,6 +341,16 @@ function fallbackCatastrophicMatch(args: Record<string, unknown> | undefined): b
301
341
  return FALLBACK_CATASTROPHIC_PATTERNS.some(re => re.test(text));
302
342
  }
303
343
 
344
+ /** First matching dangerous signal for the WS2 fallback, or null (issue #59). */
345
+ function fallbackDangerousMatch(args: Record<string, unknown> | undefined): string | null {
346
+ const text = fallbackExecSurface(args);
347
+ if (!text) return null;
348
+ for (const { re, signal } of FALLBACK_DANGEROUS_PATTERNS) {
349
+ if (re.test(text)) return signal;
350
+ }
351
+ return null;
352
+ }
353
+
304
354
  /** One-line summary of tool args for audit previews (bounded, no secrets dumped). */
305
355
  export function summariseToolArgs(args: Record<string, unknown> | undefined): string {
306
356
  if (!args) return '';
@@ -475,27 +525,58 @@ export function createInterceptor(
475
525
  };
476
526
  }
477
527
 
478
- // WS2 fail-closed path: when the real guard was never wired in or throws,
479
- // run the narrow fallback scan and DENY (throw) a catastrophic-looking
480
- // command instead of silently allowing it. The fallback recognising nothing
481
- // is not evidence the command is safe, only that it isn't one of the
482
- // handful of unambiguous shapes — everything else still falls through to
483
- // the pre-existing fail-OPEN log-and-allow.
528
+ // WS2 fail-closed path (issue #59): when the real guard was never wired in or
529
+ // throws, run the dependency-free fallback scan. Three tiers, so no dangerous
530
+ // op is ever silently allowed on a scan failure — and every could-not-scan
531
+ // decision leaves a `gate_degraded` audit row (ACTION_GUARD_FALLBACK marker),
532
+ // so forensics can distinguish "scanned & allowed" from "could not scan":
533
+ // 1. catastrophic → hard deny, always (ignores enforce:false).
534
+ // 2. dangerous → route through `failurePolicy` exactly as an unattended
535
+ // real verdict would (deny by default); enforce:false
536
+ // opts down to advisory.
537
+ // 3. no match → benign/unknown: fail OPEN (a degraded guard must not
538
+ // wedge normal work) but leave a visible breadcrumb.
484
539
  function handleGuardUnavailable(context: ToolCallContext, reason: string): void {
485
- if (!fallbackCatastrophicMatch(context.arguments)) {
486
- log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — allowing ${context.toolName}`);
540
+ const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`.slice(0, 200);
541
+ const degradedBase = {
542
+ type: 'intercept' as const, tool: context.toolName,
543
+ firewallResult: 'ACTION_GUARD_FALLBACK', trustScore: 0, sensitivityLevel: 'INTERNAL',
544
+ fragmentationScore: null, pipelineDurationMs: 0, preview, ts: new Date().toISOString(),
545
+ };
546
+
547
+ // 1. Catastrophic — hard deny, always.
548
+ if (fallbackCatastrophicMatch(context.arguments)) {
549
+ emitAudit({
550
+ ...degradedBase, severity: 'critical', threats: ['fallback-scan'], anomalyScore: 1,
551
+ action: 'auto_deny', outcome: 'auto_denied',
552
+ });
553
+ log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback scan matched a catastrophic pattern — DENYING ${context.toolName} (fail-closed, WS2)`);
554
+ throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), fallback catastrophic scan matched`);
555
+ }
556
+
557
+ // 2. Dangerous — route through failurePolicy (the "can't obtain a verdict"
558
+ // policy; a degraded guard is precisely that). enforce:false → advisory.
559
+ const dangerousSignal = fallbackDangerousMatch(context.arguments);
560
+ if (dangerousSignal) {
561
+ const dBase = { ...degradedBase, severity: 'high' as Severity, threats: ['fallback-scan', dangerousSignal], anomalyScore: 0.6 };
562
+ if (!actionGuardCfg.enforce) {
563
+ emitAudit({ ...dBase, action: 'gate_degraded', outcome: 'failure_allowed' });
564
+ log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — advisory (enforce:false), allowing dangerous ${context.toolName} [${dangerousSignal}]`);
565
+ return;
566
+ }
567
+ const failAction = config.failurePolicy.high;
568
+ emitAudit({ ...dBase, action: 'gate_degraded', outcome: failAction === 'deny' ? 'failure_denied' : 'failure_allowed' });
569
+ if (failAction === 'deny') {
570
+ log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback matched a DANGEROUS op [${dangerousSignal}] — DENYING ${context.toolName} (fail-closed, failure policy: deny)`);
571
+ throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), dangerous fallback match [${dangerousSignal}], failure policy: deny`);
572
+ }
487
573
  return;
488
574
  }
489
- const preview = `${context.toolName} :: ${summariseToolArgs(context.arguments)}`;
490
- emitAudit({
491
- type: 'intercept', tool: context.toolName, severity: 'critical',
492
- firewallResult: 'ACTION_GUARD_FALLBACK', threats: ['fallback-scan'],
493
- anomalyScore: 1, trustScore: 0, sensitivityLevel: 'INTERNAL', fragmentationScore: null,
494
- pipelineDurationMs: 0, preview: preview.slice(0, 200), ts: new Date().toISOString(),
495
- action: 'auto_deny', outcome: 'auto_denied',
496
- });
497
- log.warn(`[shieldcortex] action-guard UNAVAILABLE (${reason}) and fallback scan matched a catastrophic pattern — DENYING ${context.toolName} (fail-closed, WS2)`);
498
- throw new Error(`ShieldCortex: tool call blocked — action guard unavailable (${reason}), fallback catastrophic scan matched`);
575
+
576
+ // 3. No match — benign/unknown. Fail open, but never silently: the
577
+ // gate_degraded breadcrumb makes the outage window auditable.
578
+ emitAudit({ ...degradedBase, severity: 'low', threats: ['fallback-scan'], anomalyScore: 0.1, action: 'gate_degraded', outcome: 'failure_allowed' });
579
+ log.warn(`[shieldcortex] ⚠️ action-guard unavailable (${reason}) — allowing ${context.toolName} (fallback matched nothing; fail-open)`);
499
580
  }
500
581
 
501
582
  // Action Guard: gates non-memory tool calls (shell / file / network / git).
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shieldcortex-realtime",
3
- "version": "4.47.8",
3
+ "version": "4.47.10",
4
4
  "name": "ShieldCortex Real-time Scanner",
5
5
  "description": "Real-time defence scanning on LLM input, memory extraction on LLM output, and active tool call interception with approval gating.",
6
6
  "kind": null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakon-systems/shieldcortex-realtime",
3
- "version": "4.47.8",
3
+ "version": "4.47.10",
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",